| name | posthog-telemetry |
| description | Add or change anonymous PostHog telemetry — extend the per-tick `instance_ping` payload, add a new property to the `$identify` `$set`, fire a backend event from a hot path, or wire a frontend `track()` call. Covers the privacy posture (no exact coords / IPs / tokens / per-aircraft IDs, $geoip_disable, coarse 10° region, bucketed receiver-fingerprint values), the file map, the build-time `PosthogApiKey` MSBuild prop, the `TELEMETRY_ENABLED=0` opt-out, and the tests + README to update. Use when the user asks "add X to telemetry", "send Y to PostHog", "track when the user does Z", "is N safe to log", or anything touching `app/static/telemetry.js` / `FlightJar.Api/Telemetry/`. Don't use for the `/metrics` Prometheus endpoint or `/api/stats` — those are operator-facing and unrelated. |
PostHog telemetry
FlightJar emits two streams to PostHog:
- Backend — one
$identify on cold start + one instance_ping
every 24h (TelemetryConfig.PingInterval). Owned by
TelemetryWorker in the API project.
- Frontend — one
$pageview on map load + ad-hoc track(event, props)
calls from user-driven UI (panel opens, watchlist mutations,
channel creation, map-layer toggles, blackspots slider commit).
Owned by app/static/telemetry.js.
Both share the same per-install distinct ID (random GUID stored in
/data/telemetry.json, served to the frontend by
/api/telemetry_config) so events from both streams roll up against
the same PostHog Person.
Privacy posture (read first)
These rules are non-negotiable. The whole point of opting users into
telemetry is that the maintainer learns about aggregates and the
user reveals nothing identifying.
- Never send: exact
LAT_REF / LON_REF, SITE_NAME,
BEAST_HOST (could be a public IP), the Password, any
notification tokens (Telegram bot/chat, ntfy auth, webhook URLs),
any aircraft ICAO24 / callsign / registration / squawk, watchlist
contents.
- Always set on every event (the
Add* helpers in
TelemetryPayloadBuilder already do this — don't forget it for
ad-hoc events):
$geoip_disable: true and $ip: "" — without this, PostHog
rewrites the Person's location every time the source IP geo-
resolves to a different city.
- On the frontend the same is registered globally via
posthog.register({ $geoip_disable: true, $ip: "" }).
- Coarsen anything that could fingerprint a single install:
- Receiver location →
region_lat_10 / region_lon_10 (rounded
to nearest 10°).
- Working-set RSS → bucketed to 50 MB steps.
- Watchlist size →
"0" | "1-10" | "11-50" | "51-200" | "200+".
- Send precise where the value alone is non-identifying:
- Antenna height (MSL or AGL) is exact integer metres — there are
too many installs at any given altitude inside a 10° box for the
value to identify anyone, and the maintainer needs the resolution
to see whether mounting profiles cluster.
Before adding a new property, ask: if I joined this property with
region_lat_10, the install ID, and the version, could it pin a
single user? If yes, coarsen / bucket / drop.
File map
| File | Purpose |
|---|
dotnet/src/FlightJar.Api/Telemetry/TelemetryConfig.cs | Build-time host + API key. Reads [AssemblyMetadata("PosthogApiKey")] attributes injected by CI. |
…/InstanceIdStore.cs | Per-install GUID + first-seen timestamp, persisted to /data/telemetry.json. Rotatable via ResetAsync (called from the /api/telemetry/reset endpoint). |
…/PosthogClient.cs | Minimal HTTP client for /capture/. CaptureAsync (events) and IdentifyAsync (Person attributes). Drops on failure — telemetry must never break the host. |
…/TelemetryAccumulator.cs | Always-on bag for per-tick samples (aircraft / Comm-B / tick duration) + reconnect counter. RegistryWorker and BeastConsumerService push into it; TelemetryWorker drains on each ping. |
…/TelemetryPayloadBuilder.cs | Pure functions — Build (per-event) and BuildIdentify (Person $set + $set_once). Shared AddInstallShape + AddRegionAndAntenna helpers. |
…/TelemetryWorker.cs | Background service that ticks the worker loop. Pulls inputs from registry / accumulator / coverage / heatmap / watchlist / notifications and hands them to the builder. |
app/static/telemetry.js | Frontend bootstrap + track(event, props). Reads /api/telemetry_config, loads the PostHog snippet, registers global $geoip_disable, exposes track as a safe no-op when telemetry is off. |
dotnet/src/FlightJar.Api/Program.cs | DI wiring + the /api/telemetry_config and /api/telemetry/reset endpoints. |
How to add a per-tick property to instance_ping
Add to the dynamic block inside TelemetryPayloadBuilder.Build. If
the value comes from a moving piece of state (a counter, an
aggregate), thread it through TelemetryInputs rather than reading
AppOptions directly.
- Add a field to
TelemetryInputs (record init).
- Compute the value in
TelemetryWorker.BuildInputs and assign it.
For per-tick samples, push from RegistryWorker.DoTick into
TelemetryAccumulator, then read the drained snapshot here.
- In
TelemetryPayloadBuilder.Build, write props["my_field"] = inputs.MyField;
in the per-event block (after AddInstallShape / AddRegionAndAntenna
so install-shape fields stay grouped).
- Bucket / round if the raw value would fingerprint an install
(see the privacy section).
- Add a test:
TelemetryPayloadBuilderTests in
dotnet/tests/FlightJar.Api.Tests/Telemetry/TelemetryTests.cs.
Use the existing MakeInputs(...) helper.
- Update the README's "What's included (backend heartbeat)" bullets.
How to add a stable per-install attribute ($identify $set)
Static attributes (feature toggles, deployment shape, tuning
knobs) belong in both BuildIdentify AND Build so they
land on the Person profile and every event. Don't put drifty
counters or aggregates in $set.
- Add the property in
AddInstallShape (or AddRegionAndAntenna
for receiver-location-derived fields). That helper is called
from both Build and BuildIdentify — one edit covers both
paths.
- Test via
Identify_SplitsStableAndOnceAttributes (assert
set.ContainsKey("my_field") and that drifty fields like
uptime_s stay out of $set).
How to add a backend event (CaptureAsync)
Use this for one-shot events that aren't periodic, e.g. a state
transition you want a count for. Examples that would warrant one:
notification dispatch failure, blackspots first-compute timing,
auth lockout. Examples that would NOT — anything that fires per
aircraft / per tick (use the per-tick aggregate instead).
_ = await _posthog.CaptureAsync(
host: TelemetryConfig.Host,
apiKey: TelemetryConfig.ApiKey,
@event: "blackspots_compute_done",
distinctId: _instanceStore.InstanceId,
properties: new Dictionary<string, object?>
{
["$lib"] = "flightjar",
["$geoip_disable"] = true,
["$ip"] = "",
["altitude_m"] = altM,
["compute_ms"] = ms,
},
timestamp: _time.GetUtcNow());
The $geoip_disable + $ip = "" pair is critical for ad-hoc
events too — TelemetryPayloadBuilder adds them automatically
but a freshly-written CaptureAsync call doesn't.
How to add a frontend track() event
track(event, props) lives in app/static/telemetry.js and is a
safe no-op when telemetry isn't loaded, so the call site doesn't
need to guard. Import it from any module:
import { track } from './telemetry.js';
track('panel_open');
track('notification_channel_added', { type });
Hot-path discipline:
- Don't fire from per-snapshot redraw loops — gate on a real state
change.
openDetailPanel only fires panel_open when
state.selectedIcao !== icao.
- For sliders / continuous controls, listen on
change (commit)
not input (per-pixel drag). See app/static/blackspots.js for
the pattern.
- For map-layer toggling, hook the user-action events
(
overlayadd / overlayremove filtered by syncingOverlays),
not the setX setters — the setters also run during
localStorage restoration on page load.
Property values are bare strings / numbers / booleans (no PII).
Never include icao, callsigns, watchlist contents, or anything
the user typed in.
Build-time API key
TelemetryConfig.ApiKey is read from an [AssemblyMetadata]
attribute injected at publish time:
- CI sets the repo variable
vars.POSTHOG_API_KEY (a phc_*
project key — designed to be public-facing in client SDKs).
- The
dotnet/Dockerfile's POSTHOG_API_KEY build arg is
forwarded to dotnet publish via the MSBuild property
PosthogApiKey, which the csproj converts into the assembly
metadata.
- Local dev builds leave the value empty, so the worker no-ops
(it logs
"telemetry: no destination baked in, skipping" at
Debug). To exercise telemetry locally, build with
dotnet publish -p:PosthogApiKey=phc_dev_key.
Opt-out
TELEMETRY_ENABLED=0 disables both the backend ping AND the
frontend pageview/track stream. The flag is read from
AppOptions.TelemetryEnabled (backend) and surfaced via
/api/telemetry_config to the frontend, which checks
config.enabled before loading the PostHog snippet.
The accumulator keeps running regardless — its writes are local
and the drain is gated on the worker, so nothing leaves the
process when telemetry is off.
Tests to update
dotnet/tests/FlightJar.Api.Tests/Telemetry/TelemetryTests.cs:
- Add a
TelemetryPayloadBuilderTests [Fact] for any new
payload field. Use the MakeInputs factory helper.
- For
$set vs per-event splits, extend
Identify_SplitsStableAndOnceAttributes.
- Watchlist / RSS / antenna boundary behaviours all use
[Theory] + [InlineData] — match the style.
dotnet/tests/FlightJar.Api.Tests/Telemetry/TelemetryTests.cs's
TelemetryAccumulatorTests if you add a new accumulator metric.
tests/js/telemetry.test.js for any new export from
app/static/telemetry.js. Frontend track() call sites
themselves don't need tests — track is verified as a safe
no-op in 'track is a safe no-op before init'.
README
The "Anonymous telemetry" section in the root README enumerates
every property currently sent. It is end-user-facing — they
read it to decide whether to leave telemetry on. Update the bullets
when you add a payload field. Keep the language plain and the
"What's never included" list explicit; that's the trust boundary.
Diagnostics
- No events arriving: check
TELEMETRY_ENABLED is unset / 1,
POSTHOG_API_KEY was set at build time (docker logs flightjar
will show "telemetry: enabled, instance <guid>" on success or
"telemetry: no destination baked in" if the key wasn't baked).
Frontend: open devtools → Network → look for a POST to
eu.i.posthog.com/capture/.
- Person profile location is wrong / drifts: a code path is
emitting an event without
$geoip_disable: true and $ip: "".
Grep for CaptureAsync( and posthog.capture( outside the
central helpers and add the pair.
- Properties missing in PostHog UI: PostHog rejects events
silently when the project key is wrong. Check
/api/telemetry_config
echoes the expected api_key (note: the key is intentionally
visible to the client — that's how the JS SDK works).
- Distinct-ID mismatch (frontend events count separate from
backend pings): the frontend bootstrap calls
posthog.init(..., bootstrap: { distinctID: config.distinct_id }).
If the IDs differ, /api/telemetry_config is racing the
InstanceIdStore.LoadOrCreateAsync — confirm await ordering in
Program.cs.