ADR 0050 — Opt-in product-usage telemetry over OpenTelemetry¶
- Status: Accepted (2026-10-20)
- Date: 2026-10-20
- Deciders: @TheurgicDuke771
- Related: ADR 0010 (vendor-neutral seams), ADR 0031 (licensing, rule 40), ADR 0041 (audit events).
Context¶
There is no signal on how DataQ is actually used: which pages people visit, which check types they author, where authoring is abandoned. That signal is wanted before the first outside user arrives, and it has to be gathered in a way a security reviewer would accept on sight. These constraints are non-negotiable:
- Default OFF, with an explicit admin opt-in surfaced in Admin → Settings.
- Event names and counts only — never a table name, column name, SQL string, or credential.
- A self-hostable sink or a plain log file first — no third-party analytics dependency (licensing rule 40 and the no-egress posture).
- The event list is documented and auditable.
- The deployment-posture page discloses the setting either way.
Decision¶
-
The signals are OpenTelemetry. Every occurrence is recorded twice through the existing exporter seam (
core/otel.py):- one increment of the counter
dataq.usage.events(unit{event}), whose attributeeventnames the event, plus that event's other attributes; and - one OTel log event named
dataq.usage.<event>, carrying the same attributes.
DataQ had traces and logs and no metrics signal; this adds a
MeterProviderof its own (and aLoggerProviderof its own, so usage events never mix into the application log). Resource attributeservice.nameisdataq-apiordataq-worker. No analytics SDK is added: the OTLP metrics exporter ships in theopentelemetry-exporter-otlp-proto-httppackage DataQ already pins, and the Azure Monitor metric exporter in theazure-monitor-opentelemetry-exporterpackage it already pins. - one increment of the counter
-
Two gates, both required.
- The operator's
TELEMETRY_SINK(off·file·otlp, defaultoff).offbuilds no providers at all, so an operator can hard-disable telemetry whatever the admin chooses. - The workspace admin's opt-in, one row in
telemetry_settings(CHECK (id = 1)), default off, changed under Admin → Settings → Usage telemetry and audited astelemetry_setting.update. The emitter reads it per event through a 30-second per-process cache, so a change lands without a restart; a read that fails counts as off.
- The operator's
-
Sinks.
file— JSON lines appended toTELEMETRY_FILE_PATH: one{"type": "event", …}line per log event and one{"type": "metric", …}line per counter point (delta temporality, flushed everyTELEMETRY_EXPORT_INTERVAL_S). It works with no collector and is what an air-gapped deployment uses.otlp— the counter to<OTEL_EXPORTER_OTLP_ENDPOINT>/v1/metricsand the log events to/v1/logs, and/or to App Insights whenAPPLICATIONINSIGHTS_CONNECTION_STRINGis set: the same destinations traces and logs already use. Settings refuse to bootfilewithout a path orotlpwithout a destination.
A sink that cannot be built (an unwritable path, SDK drift) logs a warning and leaves telemetry off. Emitting never raises into the request, run or tool call it rides on.
-
The vocabulary is closed and enforced in code.
core/usage_telemetry.EVENTSdeclares every event and, per event, every attribute with either a fixed set of allowed strings or "non-negative integer". An event outside the registry, an attribute not declared for its event, or a value outside its set is dropped, not exported, and the rejection logs the attribute key only. String vocabularies are derived from the model's own enums where one exists (check kinds, engines, connection types), and the MCP tool vocabulary is the set of registered tool names. A test asserts that a table name, a SQL string, an email address and free text are refused on every string attribute of every event. -
The browser never talks to a collector. The SPA posts
page_viewedandcheck_editor_abandonedtoPOST /api/v1/telemetry/events(signed-in users only, at most 20 events per post, its own rate-limit classtelemetry, 60/minute per principal and sharing the default per-IP ceiling), where they are validated against the same registry; the server is the only emitter. The SPA reports a route name (suite_detail), never a URL, so no id or query string can leave. -
Disclosure.
GET /api/v1/admin/deploymentalways lists ausage_telemetrytransfer, off or on; when on, its detail names the sink (the file path, or the OTel exporters); when off it says why — no opt-in, no sink, or a sink that is configured but failed to start. The Admin settings page shows the effective state, the sink, and the full event list inline.
The events¶
| Event | Emitted when | Attributes |
|---|---|---|
app_started |
the api or a worker starts | service: api · worker |
user_signed_in |
a user's first GET /me in an 8-hour window, per api process (a session, not a page load) |
auth_mode: pat · session · oidc · bypass |
page_viewed |
the SPA navigates (browser-reported) | route: a route name such as dashboard, suite_detail, check_new, admin_settings, other |
check_created |
a check is saved, including each check of an imported suite | kind, engine, datasource_type, origin (user · auto · suggestion · import) |
check_editor_abandoned |
the new-check editor is left without saving (browser-reported) | step: expectation · config |
suite_run_triggered |
a run is dispatched | trigger: manual · schedule · mcp · probe · adf · airflow · dbt · suite (another suite's success, ADR 0051) · other |
run_completed |
the worker finishes a run | outcome, datasource_type, checks (count), duration_ms (omitted when the run has no start or end time) |
connection_created |
a connection is saved | type |
mcp_tool_called |
an MCP tool is called (a call naming no registered tool is not counted) | tool (a registered tool name), outcome: ok · error · cancelled |
llm_feature_used |
an LLM feature is invoked | feature: sql_generation · check_suggestion · rca_narrative |
alert_delivered |
an alert reaches a channel | channel: teams · slack · email · webhook · in_app (the in-app feed; one event per run, not per recipient) |
coverage_auto_check_created |
automatic coverage adds a check | kind |
The authoritative list, with every allowed value, is the one the Admin settings page shows
(GET /api/v1/admin/telemetry), which is read from the same registry the emitter enforces.
Pointing it at a collector¶
TELEMETRY_SINK=otlp
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
then opt in under Admin → Settings → Usage telemetry. Any OTLP/HTTP receiver works: query
dataq.usage.events grouped by event for counts, or the dataq.usage.* log events for the
per-occurrence stream. With no collector, TELEMETRY_SINK=file and
TELEMETRY_FILE_PATH=/var/lib/dataq/usage-events.jsonl give the same data as a file.
Consequences¶
- DataQ's authors still receive nothing: every destination is the operator's own.
- Adding an event is a code change to the registry, so it shows up in review and in the published list; there is no way to emit an unlisted value at runtime.
- The opt-in cache means a switch-off can take up to 30 seconds to stop emission in a worker process that has no request session to read through.
Alternatives rejected¶
- A third-party product-analytics SDK (PostHog, Segment, Mixpanel): a new data processor, egress by default, and licences outside rule 40 in several cases.
- Reusing the application log for usage events: it would mix opt-in data into a stream that is always on, and the PII redactor is a backstop, not a vocabulary.
- Browser-side OTel export: it would put a collector endpoint and a second validation path in the SPA; the server already has both.