Skip to content

Admin telemetry worker: trace transport, per-signal destinations and OTLP integration rework - #209

Merged
07prajwal2000 merged 6 commits into
Fluxify-rest:mainfrom
07prajwal2000:feature/trace-flags-and-export
Aug 6, 2026
Merged

Admin telemetry worker: trace transport, per-signal destinations and OTLP integration rework#209
07prajwal2000 merged 6 commits into
Fluxify-rest:mainfrom
07prajwal2000:feature/trace-flags-and-export

Conversation

@07prajwal2000

Copy link
Copy Markdown
Collaborator

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) — drains FLUXIFY_TRACES
and exports each run to the project's own OTLP endpoint.

  • Durable pull consumer on a Limits-retention stream: 7h max_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).
    Limits rather than Workqueue on purpose — a work queue permits one consumer
    per subject and the recording consumer (Execution recording: persist runs, list/detail endpoints and the portal viewer #208) is already planned.
  • Acks and drops on a malformed payload or a missing destination instead of
    naking. Those fail identically on every redelivery, and unlike an uncompiled
    route, a lost trace is not a broken product.
  • One tracer/meter provider per destination, LRU-capped and shut down on eviction,
    so a many-project instance does not leak socket pools.
  • Admin-plane with database access, so OTLP credentials never enter an execution
    process.

Per-signal destinations — three project_settings keys rather than a new
column. project_settings already is the key/value store, and
settings.ai.loggerConnectionId was always the project's log destination, merely
misfiled under settings.ai.*; it stays readable as the logs fallback. Separate
keys per signal because a project with a traces backend and no metrics backend is
normal.

Integration rework"Open Telemetry Logs" becomes "Open Telemetry": one
OTLP 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 Authorization or stream-name, and a
reference 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

  • getIntegrationTags returned [] for every OTEL integration — the branch
    tested "Open Observe", a string never present in the enum. The client filters
    the picker on tags, so no OTEL integration was selectable at all.
  • Both observability adapters stripped cfg: with substring(3), leaving a :
    on the key. No cfg: reference in a Loki or OTEL baseUrl or credential had
    ever resolved. Every other adapter uses slice(4).
  • TestConnection probed GET {baseUrl}/settings — an OpenObserve admin path
    that 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 settings
    connection check and by testObservibilityConnection, which switched on the
    raw variant while getSchema normalized — so a legacy row validated and then
    fell through to "Invalid variant".
  • getProjectSetting never populated its cache, so a process that does not serve
    the get-all endpoint hit the database on every lookup.
  • The JS logger api and the cloud logs block emitted nothing. The logger read
    only 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 undefined and threw. Both now
    resolve 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 nesting
intact and span_status: ERROR on the failing block, route metrics carry the
project id, a project with no destination produces no stream at all, re-publishing
a runId yields one trace rather than two, and a stopped worker drains its backlog
on 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

  • Existing installs need a tag backfill. integrations.tags is a stored column
    written at create/update, so observability rows written before the
    getIntegrationTags fix carry '' and will not appear in a filtered picker
    until re-saved:
    update integrations set tags = 'logs,metrics,traces' where "group" = 'observability' and variant like 'Open Telemetry%'
  • The stream-name header is deliberately not sent for metrics. Verified
    against 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.id attribute.
  • No sendLogs/sendTraces/sendMetrics config booleans: per-signal opt-in is
    already expressed by which settings key points at the integration, and two
    switches for one thing eventually disagree.
  • Logs still export from the execution process via the per-block connection, so
    log credentials still reach the data plane. Unifying that onto this path is
    follow-up work, not this PR.
  • Route-level toggles for tracing and recording (Route settings page in the portal: tracing and recording toggles #207) and recording persistence
    with its endpoints (Execution recording: persist runs, list/detail endpoints and the portal viewer #208) are tracked separately.

🤖 Generated with Claude Code

07prajwal2000 and others added 6 commits August 6, 2026 10:40
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>
@07prajwal2000
07prajwal2000 added this pull request to the merge queue Aug 6, 2026
Merged via the queue into Fluxify-rest:main with commit 34879f9 Aug 6, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant