Scripts

Telemetry

An opt-in record of every tool run, written only to a share or a URL your firm chooses, so a BIM manager can see which tools the team uses and what fails.

What it is for#

A firm that rolls out twenty tools rarely knows which five people actually use, which models they use them on, or which one has been failing quietly for a week. Telemetry answers those questions. When it is on, pyNavis writes one small record for every tool run: who ran which button, on which model, how long it took and whether it worked.

Telemetry is off by default. It is never sent to the pyNavis developers: records go only to the folder or URL your firm sets, and the data is yours. It records tool runs and nothing else, so there is no record of what a user does between clicks.

A run ofIs recorded as
A button's script.py click
A bundle's config.py, through Shift+Click config
A hooks\<event>.py script hook, only when includeHooks is on
A script under pynavis run run
Send test record in Settings test, which is not a tool run: filter it out before you count anything

A keyboard shortcut runs the button's script.py, so it is a click too. Some scripts never make a record: a smartbutton's own run at ribbon build (with __selfinit__ True), a dock panel's script.py, an extension's startup.py, and code typed into the console. A click the runtime refuses because another tool is still running did not run, so it makes no record either.

What a record holds#

Each run is one JSON object on a line of its own, a format called JSON Lines. The fields always come in this order:

FieldTypeHolds
schemanumber 1, the version of this record format.
timestring When the run happened, in ISO 8601 with the machine's UTC offset: 2026-10-10T14:03:22.123+10:00.
sessionstring A GUID, new each time Navisworks starts. Every run in one Navisworks session shares it.
userstringThe Windows user name.
machinestringThe computer name.
navisstring The Navisworks year, such as "2026".
pynavisstringThe pyNavis version.
docstring The open model's file name without its folder, or "" when no model is open or it has never been saved.
kindstring "click", "config", "hook", "run" or "test", as in the table above.
keystring The bundle key, the extension-relative bundle path that shortcut bindings use too: pyNavis.tab/Clash.panel/Clear_Clash.pushbutton. For a hook and a run, see below.
titlestringThe button title, on one line.
extensionstringThe extension's name.
enginestring "ironpython" or "cpython".
msnumberHow long the run took, in milliseconds.
resultstring "ok" or "error".
errorstring One line of the error, or "" when the run was ok. For a Python exception it is the last line of the traceback, the one that names the exception and its message: ValueError: the clash test has no results. Any other error gives its first line. Either way it stops at 500 characters.
resultsobject The values the script added itself. Left out when it added none.
results_droppedboolean true when the values the script added would have made the record larger than 64 KB, so they were left out. Absent otherwise.

Here is one record, spread over several lines to read. On disk it is a single line.

One record
{
  "schema": 1,
  "time": "2026-10-10T14:03:22.123+10:00",
  "session": "6f1c2d3e-8a4b-4c5d-9e6f-0a1b2c3d4e5f",
  "user": "jsmith",
  "machine": "BIM-WS-014",
  "navis": "2026",
  "pynavis": "1.4.0",
  "doc": "Level 3 Services.nwd",
  "kind": "click",
  "key": "pyNavis.tab/Clash.panel/Clear_Clash.pushbutton",
  "title": "Clear Clash",
  "extension": "pyNavis",
  "engine": "ironpython",
  "ms": 8412,
  "result": "ok",
  "error": "",
  "results": { "items": 1, "minutes_saved": 10 }
}

Hooks and runs#

A hook and a script under pynavis run are not buttons, so a few fields hold something else:

FieldA hook recordA run record
key The extension and the event, pyNavis:doc-opened. The bundle key when the script is a button's script.py, otherwise the script's file name, count_items.py.
title The event, doc-opened. That button's title, otherwise the file name without .py, the same __title__ the script sees.
extension The extension the hook belongs to. That button's extension, otherwise "".
doc The open model. The model the run opened.
ms How long the hook ran. The same duration pynavis run reports for the script.

A hidden Navisworks that pynavis run starts closes as soon as its script finishes, so pyNavis sends each run's record straight away instead of waiting for the next flush. It tries every destination, even one it is waiting to retry after a failure, and waits at most two seconds for them.

Add your own values#

The fields above say that a tool ran. Only the tool knows what the run achieved, so a script can add its own values to its record with telemetry.note:

script.py
from pynavis import selection, telemetry, toast

items = selection.get_items()
if not items:
    toast.warning('Select something first.')
else:
    # ... the tool's work ...
    telemetry.note(items=len(items), minutes_saved=10)
    toast.success('Updated %d items.' % len(items))

Each call merges its names into the current run's results, so a script can call it several times and a repeated name replaces the earlier value. With telemetry off, or outside a run, the call is ignored, which means library code can call it without first checking where it is. It never raises. Strings, numbers, booleans, None, and lists and dicts of them are written as they are; anything else is written as its str(), and a number JSON cannot hold, such as float('nan'), as null. The values are copied when you note them, so changing a list afterwards does not change the record. A record is capped at 64 KB: when the noted values would make it larger, they are left out and the record carries "results_dropped": true instead, so keep notes to counts and short values.

Only clicks, Shift+Clicks, hooks and runs have a record to add to. A note from startup.py, a dock panel's script or a smartbutton's ribbon-build run goes nowhere, even when it happens during another tool's run, as startup.py does inside the Reload button's. Code typed into the console has no record to add to either.

Note what a manager would add up

Counts and estimates are what make the data useful later: items changed, clashes grouped, sheets exported, minutes saved against doing it by hand. A sum of minutes_saved across a month is a number a BIM manager can take to a meeting.

What you note leaves the machine

Everything in results goes to the firm's share or URL with the rest of the record. Note counts and flags, not element names, property values or anything else from the model that should stay in it.

Turn it on#

Telemetry is set per user, in a telemetry section of %APPDATA%\pyNavis\config.json:

%APPDATA%\pyNavis\config.json
{
  "telemetry": {
    "enabled": true,
    "folder": "\\\\server\\bim\\pynavis-telemetry",
    "url": "https://example.com/pynavis",
    "includeHooks": false
  }
}
KeyTypeDefaultWhat it controls
enabledbooleanfalse Whether runs are recorded at all.
folderstringabsent A folder, usually a network share, to write records into.
urlstringabsent An address to post records to.
includeHooksbooleanfalse Whether hook runs are recorded too. A selection-changed hook runs far more often than any button, so hooks are left out unless you ask.

folder and url are each optional: set either one, or both, and each one you set receives every record. With neither set nothing is recorded, even with enabled on. JSON writes a backslash twice, so the share \\server\bim\pynavis-telemetry is "\\\\server\\bim\\pynavis-telemetry" in the file.

Settings on the pyNavis panel has a Telemetry section with the same four fields, so nobody has to edit the file by hand. A change saved there takes effect at once, without a Reload; an edit to config.json itself takes effect on the next Reload. Saving or reloading never waits for a slow destination: records the old settings still held go out in the background. Its Send test record button sends one record, of kind test, to the destinations the section shows, saved or not. It tries every destination at once, even one pyNavis is waiting to retry after a failure, and the status line under it says where the record went, or, for each destination that did not take it, why. Use it after any change: a delivery that fails is never shown to the user, so the button is the quickest way to know the setup works.

Deploy it for a firm#

Asking every user to edit their own settings does not scale, and a user could turn telemetry off again. A firm deploys one file to each machine instead:

%PROGRAMDATA%\pyNavis\telemetry.json
{
  "enabled": true,
  "folder": "\\\\fileserver\\bim\\pynavis-telemetry",
  "includeHooks": false
}

It takes the same four keys as the telemetry section. When the file exists its values win over the user's own, the Settings window shows them read-only as managed by the firm, and the user cannot change them. The file decides every key: one it leaves out takes its default, not the user's value. A file that does not parse is logged and ignored, so a typo on one machine falls back to that user's own settings rather than switching anything on or off. pyNavis reads the file when Navisworks starts and on every Reload.

pyNavis only trusts the file when administrators alone can change it: it must be owned by SYSTEM, Administrators or TrustedInstaller, and nobody else may write to it, the same rule the loader applies to the machine-wide extensions folder. Any user can create files under %PROGRAMDATA%, so a file a user could edit might have been planted by anyone on the PC to send everyone's records somewhere of their choosing. Such a file is ignored, with one line in the pyNavis log naming it, and each user's own settings apply instead.

Deploy it the way your firm deploys anything else to a machine: an Intune script or a Group Policy computer startup script. Both run with administrator rights, so the file they write is owned by an administrator account and users can read it but not edit it. A login script runs as the user, so the file it writes is that user's own, and pyNavis ignores it.

Intune or startup script
$dir = Join-Path $env:ProgramData 'pyNavis'
New-Item -ItemType Directory -Force $dir | Out-Null
$json = @'
{
  "enabled": true,
  "folder": "\\\\fileserver\\bim\\pynavis-telemetry",
  "includeHooks": false
}
'@
[System.IO.File]::WriteAllText((Join-Path $dir 'telemetry.json'), $json)

Where the data goes#

Recording never slows a tool down. Each record joins a queue in memory, and a background worker writes the queue out every few seconds and once more when Navisworks closes. A click never waits for the share or the network.

To a folder#

Records are appended to one file per machine, user and day:

text
<folder>\<machine>-<user>-<yyyy-MM-dd>.jsonl
\\fileserver\bim\pynavis-telemetry\BIM-WS-014-jsmith-2026-10-10.jsonl

Because no two people ever write to the same file, a whole office can point at one share. Two Navisworks windows of one user share that day's file, but they write to it in turn, so their lines never mix. Users need permission to create and write files there; only whoever reads the data needs to read them.

To a URL#

The queued records are posted as a JSON array:

Part of the requestValue
MethodPOST to the url exactly as set.
BodyA JSON array of records, each one the object described above.
Content-Typeapplication/json
User-AgentpyNavis/<version>
Timeout10 seconds.
ProxySigned in with the user's Windows credentials when a proxy asks for them.
SuccessAny 2xx status.
RefusedAny 4xx status except 407, 408 and 429. The records in that request are dropped, not retried.
FailureAnything else, including no answer. The records are kept and retried.

A 4xx answer means the server read the request and will not take it, so sending the same records again would only be refused again and hold up every record behind them. pyNavis drops that request's records, writes one line to the log with the status and how many records went, and carries on with the rest. The exceptions are the answers that may pass later: 407 (the proxy wants credentials), 408 (the server timed out waiting) and 429 (too many requests), which are retried like any other failure.

When the destination is unreachable#

Records that cannot be delivered, because the share is offline or the URL fails, are kept on the user's machine in %APPDATA%\pyNavis\telemetry\, one file per destination (folder.jsonl and http.jsonl), and tried again on later flushes and in the next Navisworks session. After repeated failures the retries space out, up to five minutes apart, so an unreachable server is not hammered. Each spool file is capped at 5 MB; past that the oldest records are dropped first, so a laptop that spends a month away from the office keeps its most recent runs. A record leaves the spool only once its destination has taken it, so closing Navisworks in the middle of a slow delivery loses nothing that was waiting there.

The first failure is written to the pyNavis log, and the log then stays quiet about that destination until a delivery to it works again. Nothing is shown to the user: no toast, no dialog, no slower click.

Privacy#

Turning telemetry on is the firm's decision, and people should know before it happens. This is exactly what the data holds and does not hold.

RecordedNever recorded
The Windows user name and the computer name, in plain text, not hashed. The model's folder path. Only its file name is kept.
The model's file name. Script contents.
Which tool ran, when, for how long, and whether it worked. The selection.
One line of an error. Geometry.
The values a script notes itself. Anything between runs: views, navigation, what the user looked at.

The data goes only where the firm points it: the folder, the URL, and the spool file on the user's own machine while a delivery is waiting. None of it goes to the pyNavis developers.

An error's line says whatever the error says

pyNavis keeps only one line of an error, the exception and its message, but keeps it as written, and an error message can carry a path or a name: Python's own message for a file it could not open includes the file's full path. If a tool of yours handles project paths or names, word the exceptions it raises so their message does not carry them.

Read the data in Power BI#

Power BI reads a folder of JSON Lines files with a short Power Query script. The Combine button cannot do it on its own, because it expects every file to be one CSV, Excel or JSON document, and a JSON Lines file is many JSON documents, one per line.

  1. In Power BI Desktop choose Get data > Folder, and give it the share, such as \\fileserver\bim\pynavis-telemetry.
  2. In the list of files choose Transform Data, not Combine.
  3. In Power Query choose Advanced Editor and replace the whole query with the one below. Change the path on its Source line to your share.
  4. Choose Close & Apply.
Power Query (M)
let
    Source = Folder.Files("\\fileserver\bim\pynavis-telemetry"),
    Files = Table.SelectRows(Source, each [Extension] = ".jsonl"),
    WithLines = Table.AddColumn(Files, "Line",
        each Lines.FromBinary([Content], null, null, 65001)),
    OneRowPerLine = Table.ExpandListColumn(Table.SelectColumns(WithLines, {"Line"}), "Line"),
    Parsed = Table.AddColumn(OneRowPerLine, "Record",
        each try Json.Document([Line]) otherwise null),
    Valid = Table.SelectRows(Parsed, each [Record] <> null),
    Runs = Table.ExpandRecordColumn(Table.RemoveColumns(Valid, {"Line"}), "Record",
        {"schema", "time", "session", "user", "machine", "navis", "pynavis", "doc",
         "kind", "key", "title", "extension", "engine", "ms", "result", "error",
         "results", "results_dropped"}),
    Typed = Table.TransformColumnTypes(Runs,
        {{"schema", Int64.Type}, {"time", type datetimezone}, {"ms", Int64.Type}}),
    ToolRuns = Table.SelectRows(Typed, each [kind] <> "test")
in
    ToolRuns

That gives one row per run. Lines.FromBinary splits each file into lines, Json.Document parses each line, and the try skips a blank line or one caught half-written while a user's Navisworks was appending to it; that line is read whole on the next refresh. The last step drops the records Send test record made. results stays a record column: expand it in the editor to get one column for each name your scripts note.

Some first visuals worth building: runs per title, the proportion of each tool's runs whose result is error, the most common error lines, runs per doc, and a monthly sum of minutes_saved.

Scheduled refresh needs a gateway

Power BI Desktop reads the share directly. A report published to the Power BI service can only refresh from a file share through an on-premises data gateway, so set one up, or refresh from Desktop and publish again.

In Excel#

Excel has the same Power Query engine. Choose Data > Get Data > From Other Sources > Blank Query, open Advanced Editor, paste the same query, and Close & Load gives a table to pivot. Opening a .jsonl file directly in Excel does not give you columns.

A receiver for the URL route#

A share is the simplest destination. A URL suits a firm whose offices cannot all reach one share, or that wants the records in a database. Whatever answers the URL has one job: accept a POST of a JSON array and reply with a 2xx status.

This is a starting point, not a production server. It is Python 3 with the standard library only, and it appends every record it receives to telemetry.jsonl beside it, so the Power BI query above reads its output too.

receiver.py
"""A minimal pyNavis telemetry receiver. A starting point, not a hardened server."""
import json
from http.server import BaseHTTPRequestHandler, HTTPServer

LOG = 'telemetry.jsonl'
PORT = 8080


class Receiver(BaseHTTPRequestHandler):
    def do_POST(self):
        try:
            length = int(self.headers.get('Content-Length') or 0)
            records = json.loads(self.rfile.read(length).decode('utf-8'))
        except ValueError:
            records = None
        if not isinstance(records, list) or not all(isinstance(r, dict) for r in records):
            self.send_error(400, 'Expected a JSON array of records')
            return
        with open(LOG, 'a', encoding='utf-8') as fh:
            for record in records:
                fh.write(json.dumps(record, ensure_ascii=False) + '\n')
        self.send_response(204)
        self.end_headers()


if __name__ == '__main__':
    HTTPServer(('', PORT), Receiver).serve_forever()

Run it with python receiver.py on a machine your offices can reach, set url to that machine and port, and press Send test record in Settings: a line appears in telemetry.jsonl. Anything that is not a JSON array of objects gets a 400, which pyNavis takes as a refusal: it drops those records and logs it rather than retrying them.

Before it serves a whole firm

The script accepts a POST from anyone who can reach it, speaks plain HTTP, handles one request at a time and grows one file forever. Keep it on your own network, put it behind your web server for HTTPS, and rotate or load the file into a database once it matters.