Admin telemetry worker: trace transport, per-signal destinations and OTLP integration rework - #209
Merged
07prajwal2000 merged 6 commits intoAug 6, 2026
Conversation
feat(routes): tracing and recording flags through the compile pipeline Two independent switches on routes: tracingEnabled exports spans to the project OTEL destination and stores nothing; recordExecution persists a debug recording for the portal viewer and is expensive, so it defaults off. Both travel schema -> RouteArtifact -> HttpRoute -> dispatch, alongside a routeVersion that identifies the graph a recorded run belongs to. Route versioning does not exist yet, so it is the compile timestamp. HttpRouteParser copied a hand-listed field subset into the match leaf and again into the getRouteId return; the leaf now carries the whole HttpRoute, so route fields reach dispatch without three edits each. Refs Fluxify-rest#195 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvmWexDWB1uqzyhvDU6eKv @
feat(blocks): span timing and custom-block trace scoping Spans carried no time, so a trace could not report a duration. Block functions now read performance.now() on entry and again when recording, both only when a trace is attached. A nested graph shares the caller Context, so its spans landed flat in the parent trace with nothing saying which block invoked them or which canvas their block ids belong to. lib.invoke/invokeAsync take the invoking block id and open a scope for the invocation. A detached async invocation gets its own trace, since it outlives the request that started it. Refs Fluxify-rest#195 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GvmWexDWB1uqzyhvDU6eKv @
Session 2 of Fluxify-rest#195: the export layer on its own, provable without NATS or a telemetry worker. `@fluxify/common/otlp` translates a recorded route execution into OTEL spans and route metrics, and pushes both over OTLP. Why translate rather than write an OTLP client: `SpanOptions.startTime` and `Span.end(endTime)` accept historical timestamps, which was the only thing that could have forced a hand-rolled one. Rolling our own would mean owning protobuf encoding, retry, gzip and partial-success handling for no gain. - `TraceRunPayload` is the wire contract, defined next to the exporter so the producer (session 1) and consumer (session 2.1) cannot drift. It carries `Date.now()` and `performance.now()` sampled together: span times are monotonic readings, so without the pair every exported span is timestamped wrong. - Ids derive from `runId`/`seq`, so a redelivered run lands on the same trace instead of duplicating it, a detached async run can link to its parent without export-time state, and a trace is addressable from a run id. - Spans export ordered by start time, not `seq`: a block is recorded on completion, so a custom block's children are recorded before the block that invoked them. - `BasicTracerProvider`, never `NodeTracerProvider` — the latter pulls `@opentelemetry/instrumentation`, which loads `node:v8` and breaks every `@fluxify/common` importer under Bun. Providers are unregistered and own no process hooks; the caller holds them. - `flushTelemetry`/`shutdownTelemetry` absorb a Bun quirk: the OTLP transport registers `req.on('close')` unconditionally and reports it as a timeout, so every successful flush also reports a failure. `forceFlush` rejects with an *array* of errors, so the obvious `instanceof Error` guard never fires. Verified against the local stack: a run lands in Jaeger as one trace with correct wall-clock times, custom-block nesting and an exception on the failing block; route metrics reach Prometheus over OTLP push. Live checks are opt-in behind `OTLP_LIVE_TEST=1` so CI stays offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…OTLP integration rework
Adds the deployment that drains FLUXIFY_TRACES and exports runs to a project's
own OTLP endpoint, plus the configuration surface it needs.
Worker: durable pull consumer on a Limits-retention stream (7h max_age, 512 MiB,
discard old) so telemetry can never pressure artifact delivery. A malformed
payload or a project with no destination is acked and dropped rather than naked
— unlike the compiler, a lost trace is not a broken product. One tracer/meter
provider per destination, LRU-capped and shut down on eviction.
Destinations: three project_settings keys, one per signal, instead of a new
column — project_settings already is the key/value store, and
settings.ai.loggerConnectionId was always the log destination, misfiled under
settings.ai.*. It stays readable as the logs fallback. Every lookup goes through
ownsIntegration so a shared cache cannot leak another tenant's credentials.
Integrations: the "Open Telemetry Logs" variant becomes "Open Telemetry" — one
OTLP endpoint carries all three signals and the old name described only the
first. Stored rows are normalized on read; the alias is deliberately absent from
the variant enum so the dropdown does not offer the same thing twice. Custom
headers land on both OTEL and Loki with cfg: resolution, for ingestors keyed on
an api key or tenant id; user headers are spread first so they cannot clobber
auth or stream routing, and a reference resolving to nothing is dropped rather
than sent empty.
Fixes found on the way:
- getIntegrationTags returned [] for every OTEL integration (the branch tested
"Open Observe", never present in the enum), so the client could not select one.
- Both observability adapters stripped cfg: with substring(3), leaving a ":" on
the key — no cfg: reference in a Loki/OTEL baseUrl or credential had ever
resolved. Every other adapter uses slice(4).
- getProjectSetting never populated its cache, so a process not serving the
get-all endpoint hit the database on every lookup.
- TestConnection probed GET {baseUrl}/settings, an OpenObserve admin path any
other collector 404s. It now posts an empty OTLP batch to the endpoint of the
signal being tested, which ingests nothing and answers 200 from a working
receiver, 401 from one that rejects the credentials.
Also drops the jaeger, grafana and prometheus containers: OpenObserve serves all
three signals and the extra stack was only there to read them.
Verified end to end against NATS, Postgres and a live OpenObserve — one trace
with nesting and error status intact, route metrics carrying the project id, a
project with no destination producing nothing, and a restart draining the
backlog without replaying what was already exported.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three IntegrationSelectors, one per signal, each filtered by its tag — so a Loki endpoint never appears under traces or metrics, and an OTLP one appears under all three. Until now the settings.telemetry.* keys could only be written by API or SQL. The logs picker displays settings.ai.loggerConnectionId when the new key is unset, so an existing project shows its current destination without a migration. Writes always go to the new key. Test connection passes the signal through, so the plug icon beside Traces probes the traces endpoint rather than logs. Adds the upsert call to the project settings service — the portal could only read them before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…loud logs factory Two independent reasons a project could emit no logs at all. The JS `logger` api read only settings.ai.loggerConnectionId, so a project configured through the current UI — which writes settings.telemetry.logsConnectionId — fell through to the server console. It now reads new key first, legacy second, the same order the telemetry worker uses. The cloud logs block calls context.integrationFactory, which only the legacy interpreted path ever supplied. Compiled routes hit a non-null assertion on undefined, so the block threw for every one of them. createContext now provides it; an unknown or another project's integration id degrades to the console rather than throwing, and non-observability groups return undefined since db, kv and ai have their own factories. Both now resolve through one helper, which also stops the old code writing projectId/routeId into the shared cache entry — that mutation is the cross-labelling race in Fluxify-rest#204, where two concurrent requests on different routes stamp each other's ids. Providers are still built per request; that half of Fluxify-rest#204 stands. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Closes the transport half of #195. A route could already be marked as traced and
the span data already existed, but nothing carried it anywhere: there was no
consumer, no way for a project to say where its telemetry should go, and no UI to
say it in.
What
Telemetry worker (
deployments/telemetryWorker.ts) — drainsFLUXIFY_TRACESand exports each run to the project's own OTLP endpoint.
Limits-retention stream: 7hmax_age, 512 MiB,discard: old, so telemetry volume can never pressure artifact delivery (P1: artifact boot loads twice; every update rebuilds the whole route trie #191).Limitsrather thanWorkqueueon purpose — a work queue permits one consumerper subject and the recording consumer (Execution recording: persist runs, list/detail endpoints and the portal viewer #208) is already planned.
naking. Those fail identically on every redelivery, and unlike an uncompiled
route, a lost trace is not a broken product.
so a many-project instance does not leak socket pools.
process.
Per-signal destinations — three
project_settingskeys rather than a newcolumn.
project_settingsalready is the key/value store, andsettings.ai.loggerConnectionIdwas always the project's log destination, merelymisfiled under
settings.ai.*; it stays readable as the logs fallback. Separatekeys per signal because a project with a traces backend and no metrics backend is
normal.
Integration rework —
"Open Telemetry Logs"becomes"Open Telemetry": oneOTLP endpoint carries all three signals and the old name described only the first.
Stored rows are normalized on read, so no data migration. The alias is
deliberately not in the variant enum — that enum renders the dropdown, and an
alias there would offer the same thing twice.
Custom headers on both OTEL and Loki,
cfg:-resolved like any other credential,for ingestors keyed on an api key or tenant id instead of basic auth. User headers
are spread first so they cannot clobber
Authorizationorstream-name, and areference that resolves to nothing is dropped rather than sent empty — an empty
api key reads at the far end as a wrong credential, which is much harder to debug
than an absent one.
Portal UI — three integration pickers on project settings, each filtered by its
tag, so Loki never appears under traces or metrics. Test connection passes the
signal through.
Also drops the jaeger, grafana and prometheus containers: OpenObserve serves all
three signals and that stack existed only to read them.
Bugs fixed along the way
getIntegrationTagsreturned[]for every OTEL integration — the branchtested
"Open Observe", a string never present in the enum. The client filtersthe picker on tags, so no OTEL integration was selectable at all.
cfg:withsubstring(3), leaving a:on the key. No
cfg:reference in a Loki or OTELbaseUrlor credential hadever resolved. Every other adapter uses
slice(4).TestConnectionprobedGET {baseUrl}/settings— an OpenObserve admin paththat any other collector 404s, so a generic OTLP endpoint read as unreachable.
It now posts an empty OTLP batch to the endpoint of the signal being tested:
ingests nothing, 200 from a working receiver, 401 from one that rejects the
credentials.
settings.telemetry.*and the legacy variant were rejected by the settingsconnection check and by
testObservibilityConnection, which switched on theraw variant while
getSchemanormalized — so a legacy row validated and thenfell through to "Invalid variant".
getProjectSettingnever populated its cache, so a process that does not servethe get-all endpoint hit the database on every lookup.
loggerapi and the cloud logs block emitted nothing. The logger readonly the legacy settings key, so a project configured through the new UI logged
to the server console. Separately, the cloud logs block calls
context.integrationFactory, which only the legacy interpreted path supplied —compiled routes hit a non-null assertion on
undefinedand threw. Both nowresolve through one helper, which also stops the old code mutating the shared
integration cache entry (the cross-labelling race in P1: compiled runtime logging lifecycle for OpenTelemetry and Loki #204).
Verification
End to end against NATS, Postgres and a live OpenObserve: a run published to
fluxify.trace.<projectId>.<runId>arrives as one trace with custom-block nestingintact and
span_status: ERRORon the failing block, route metrics carry theproject id, a project with no destination produces no stream at all, re-publishing
a
runIdyields one trace rather than two, and a stopped worker drains its backlogon restart without replaying what it already exported. The per-signal probe was run
against all three live endpoints, including the bad-credential and unreachable
cases.
Repo lint 8/8; 598 tests pass, 21 of them new.
Notes for review
integrations.tagsis a stored columnwritten at create/update, so observability rows written before the
getIntegrationTagsfix carry''and will not appear in a filtered pickeruntil re-saved:
update integrations set tags = 'logs,metrics,traces' where "group" = 'observability' and variant like 'Open Telemetry%'stream-nameheader is deliberately not sent for metrics. Verifiedagainst a live OpenObserve: it ignores the header there and names the stream
after the metric, so sending it would be a knob that silently does nothing. The
project dimension for metrics is the
fluxify.project.idattribute.sendLogs/sendTraces/sendMetricsconfig booleans: per-signal opt-in isalready expressed by which settings key points at the integration, and two
switches for one thing eventually disagree.
connection, solog credentials still reach the data plane. Unifying that onto this path is
follow-up work, not this PR.
with its endpoints (Execution recording: persist runs, list/detail endpoints and the portal viewer #208) are tracked separately.
🤖 Generated with Claude Code