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 of | Is 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:
| Field | Type | Holds |
|---|---|---|
schema | number | 1, the version of this record format. |
time | string | When the run happened, in ISO 8601 with the machine's UTC offset:
2026-10-10T14:03:22.123+10:00. |
session | string | A GUID, new each time Navisworks starts. Every run in one Navisworks session shares it. |
user | string | The Windows user name. |
machine | string | The computer name. |
navis | string | The Navisworks year, such as "2026". |
pynavis | string | The pyNavis version. |
doc | string | The open model's file name without its folder, or "" when no model is
open or it has never been saved. |
kind | string | "click", "config", "hook",
"run" or "test", as in the table above. |
key | string | 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. |
title | string | The button title, on one line. |
extension | string | The extension's name. |
engine | string | "ironpython" or "cpython". |
ms | number | How long the run took, in milliseconds. |
result | string | "ok" or "error". |
error | string | 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. |
results | object | The values the script added itself. Left out when it added none. |
results_dropped | boolean | 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.
{
"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:
| Field | A hook record | A 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:
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.
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.
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:
{
"telemetry": {
"enabled": true,
"folder": "\\\\server\\bim\\pynavis-telemetry",
"url": "https://example.com/pynavis",
"includeHooks": false
}
}| Key | Type | Default | What it controls |
|---|---|---|---|
enabled | boolean | false |
Whether runs are recorded at all. |
folder | string | absent | A folder, usually a network share, to write records into. |
url | string | absent | An address to post records to. |
includeHooks | boolean | false |
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:
{
"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.
$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:
<folder>\<machine>-<user>-<yyyy-MM-dd>.jsonl
\\fileserver\bim\pynavis-telemetry\BIM-WS-014-jsmith-2026-10-10.jsonlBecause 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 request | Value |
|---|---|
| Method | POST to the url exactly as set. |
| Body | A JSON array of records, each one the object described above. |
Content-Type | application/json |
User-Agent | pyNavis/<version> |
| Timeout | 10 seconds. |
| Proxy | Signed in with the user's Windows credentials when a proxy asks for them. |
| Success | Any 2xx status. |
| Refused | Any 4xx status except 407, 408 and 429. The records in that request are dropped, not retried. |
| Failure | Anything 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.
| Recorded | Never 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.
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.
- In Power BI Desktop choose Get data > Folder, and
give it the share, such as
\\fileserver\bim\pynavis-telemetry. - In the list of files choose Transform Data, not Combine.
- In Power Query choose Advanced Editor and replace the whole query with
the one below. Change the path on its
Sourceline to your share. - Choose Close & Apply.
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
ToolRunsThat 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.
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.
"""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.
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.