Skip to content

Orvanta Telemetry Standards

This is a reference for anyone building dashboards, alerts, or trace queries against Orvanta’s OTLP output — the exact span names, attribute namespaces, and rules the platform’s tracing code follows, taken from the code that emits them rather than from a generic OpenTelemetry style guide. For the practical “what will I see” tour, read Observing your Flows first. For turning tracing on, see Tracing & Logging with OpenTelemetry.

Nearly every cross-process link in Orvanta’s tracing is a pure function of a job’s UUID, not something stored or passed along:

trace_id = job_id.as_u128().to_be_bytes() // 16 bytes → 16 bytes
span_id = job_id.as_u64_pair().1.to_be_bytes() // low 8 bytes → 8 bytes

A UUID is exactly 16 bytes and a W3C trace id is exactly 16 bytes, so the mapping is total: every job id has a trace id, and it’s the same one every time, computed independently by any process that knows the job id.

This exists because of how BPMN — and, less dramatically, any parked/suspended job — actually executes: a process instance is driven by many separate pulls of one queue row, not one continuous execution. A naive span-per-execution would fragment a single logical run into an unbounded number of disconnected traces. Deriving the trace id from the job’s own (immutable) UUID means every pull, on every worker, after any crash replay, recomputes the identical id with zero coordination and no schema change.

The one place derivation cannot reach is the caller. A CLI invocation or an inbound webhook already has its own trace before the job it triggers even exists, so that id isn’t derivable from anything on the server. That boundary uses real W3C traceparent propagation instead (see CLI and inbound HTTP below) — and even there, the job’s own derived trace is never overwritten. The caller’s context is recorded as a link, in both directions, never a reparent, because GET /jobs/get_otel_traces/:id looks a job’s spans up by trace_id = job_id; reparenting the job’s root span would silently empty that lookup.

SpanEmitted forNotes
full_jobEvery root job, on completionTrace/span id derived from the job UUID. Child jobs don’t get their own full_job — they nest into the root’s trace instead.
jobEvery job’s own executionThe general-purpose per-job span (worker.rs::create_span_with_name).
job_postprocessingA job’s completion handlingEmitted by the result processor after job ends.
requestEvery inbound API requestParented onto an inbound traceparent when the caller sent one.
job_requestedInside a root job’s own trace, when the request that created it carried a traceparentCarries a link to the caller’s span. This is how a CLI- or webhook-triggered job stays discoverable by trace_id = job_id while still showing where it came from.
bpmn.stepOnce per pull of a BPMN process jobSiblings across an async re-pull — several bpmn.step spans under one instance is normal, not a bug.
bpmn <elementId>Once per BPMN element execution that closed during a pulle.g. bpmn Task_1. Not emitted for an element that’s still open (entered but not yet closed) — see bpmn.element.entry_observed below.

There’s no OpenTelemetry semantic-convention namespace for BPMN, so Orvanta defines its own — and defers to opentelemetry-semantic-conventions wherever a standard attribute already exists rather than inventing a parallel one.

  • bpmn.* — facts about the process model or its runtime state: bpmn.process.definition_id, bpmn.process.lifecycle, bpmn.element.id, bpmn.element.type, bpmn.element.name, bpmn.token.id, bpmn.scope.id, bpmn.sequence_flow.id, bpmn.transition, bpmn.element.entry_observed, bpmn.step.dispatched_activities, bpmn.step.live_tokens, bpmn.step.async_boundary_pending.
  • orvanta.* — facts about the platform, independent of BPMN: orvanta.workspace_id, orvanta.process_job_id, orvanta.bpmn_flow_path, orvanta.child_job_id.

bpmn.element.type is a bounded, low-cardinality label taken from a fixed set (task, scriptTask, serviceTask, userTask, exclusiveGateway, startEvent, …). ElementKind::Unsupported(reason) deliberately collapses to the constant string "unsupported" — the free-form diagnostic reason is dropped rather than leaked into the one type attribute meant to be safely aggregatable.

One inconsistency worth knowing about: the full_job span predates this namespace split and uses bare attribute names — job_id, workspace_id, script_path, job_kind, created_by — with no prefix at all. The BPMN spans added under #606 are the first to follow the bpmn.* / orvanta.* convention; don’t assume every span in the platform follows it yet.

Element history is capped (2,000 records per instance). entry_observed = false means the element’s opening Entered transition had already rotated out of that capped history by the time its Completed/Failed arrived — so the span’s start time is a floor, not the true entry time. true means the interval is exact.

Element ids, token ids, scope ids, and job ids are span attributes and span names only — never metric labels. The Flowable conformance corpus alone is 1,754 models; a per-element counter would mint an unbounded time series per definition per workspace. Concretely:

  • The BPMN tracing code (otel_bpmn.rs) registers no metric instrument at all. bpmn.step and bpmn <elementId> durations are span durations, aggregated at query time by your trace backend — not counters or histograms emitted by Orvanta.
  • Any BPMN metric added in the future must be labelled by workspace_id + bpmn.element.type + transition and nothing else. High-cardinality dimensions like element id or token id stay out of the metrics pipeline permanently, by design — not because no one’s gotten to it yet.

Where instrumentation may — and may not — live

Section titled “Where instrumentation may — and may not — live”

The BPMN engine’s core stepping function, step_to_quiescence, is pure and I/O-free by design, and that purity is load-bearing for the engine’s crash-replay guarantees. Telemetry is emitted only from the I/O side, after commit_step has committed — never from inside the pure step function, not even as a metric. Two consequences:

  • A step that rolled back emits nothing; its replay emits exactly what the committed step would have. The trace is honest under crash replay by construction.
  • Emission adds no query, no lock, and no row — it observes the step’s already-committed output rather than participating in producing it.

If you configure sampling — via your collector or OTEL_TRACES_SAMPLER — it must be traceidratio (head-based, keyed on the trace id), never a per-span random sampler. Because a BPMN instance’s trace id is derived independently on every pull, only a sampler whose decision is a deterministic function of the trace id makes every pull agree; a per-span RNG would shred exactly the long-running instances this tracing exists to observe. Tail sampling is off the table for the same reason from the other direction: spans belonging to one BPMN trace can be emitted hours apart, well past any realistic collector’s decision window.

Whether a running script can see TRACEPARENT / OTEL_TRACE_ID / OTEL_SPAN_ID depends on how that language executes — there are exactly two categories, and every supported ScriptLang is classified as one or the other in an exhaustive match (orvanta-worker/src/otel_script_context.rs::trace_context_delivery) that fails to compile if a new language isn’t classified:

DeliveryLanguagesWhy
Process envBun, Bunnative, Deno, Python3, Go, Bash, PowerShell, PHP, Rust, Ansible, C#, Nu, Java, Kotlin, Groovy, Ruby, R, Dart, MongoDBThe executor spawns a separate OS process per job to run user code, so the variables are set on that process. Under nsjail they still arrive — every sandbox config sets keep_env: true.
In-worker processNative TypeScript (deno_core), Postgres, MySQL, MSSQL, Oracle, BigQuery, Snowflake, DuckDB, GraphQLThe “user code” runs inside the worker’s own process or is a statement handed to an in-process Rust driver — there is no per-job OS process to set an environment variable on.

Two more constraints, both deliberate rather than incidental:

  • Dedicated workers get no TRACEPARENT, regardless of language. A dedicated worker is one long-lived interpreter reused across many jobs; a per-job environment variable is structurally wrong on a process that outlives any single trace.
  • The OTLP endpoint itself is never handed to a script. A script that wants to emit its own spans configures its own exporter — piping the collector address into every sandboxed script would be a new egress path the platform decided on unilaterally.

CLI and inbound HTTP: the one real propagation boundary

Section titled “CLI and inbound HTTP: the one real propagation boundary”

The orvanta CLI and any other external caller are the exception to “everything is derived.” The CLI:

  • Creates one span per invocation (named after the leading sub-command words only — arguments are excluded as high-cardinality, secret-bearing data), exported over OTLP.
  • Installs the OpenTelemetry SDK’s own W3CTraceContextPropagator and injects a traceparent header on every outbound API call, entirely inert (no SDK import, no span, no header) unless OTEL_EXPORTER_OTLP_ENDPOINT or OTEL_EXPORTER_OTLP_TRACES_ENDPOINT is set in the CLI’s own environment.

Server-side, orvanta_common::otel_propagation installs the matching propagator unconditionally at startup (installing it costs nothing even with tracing off) and:

  1. Parents the API’s request span onto the inbound context, so the server’s own spans join the caller’s trace.
  2. Emits a job_requested span inside the job’s own (derived) trace, linking to the caller’s span — never reparenting the job’s root span itself.
  3. Adds a link from the current (caller-side) span to the job’s root span, so the caller’s trace shows the job it started.

Both halves are span links, deliberately, in both directions — reparenting either span would break the trace_id ↔ job_id round-trip GET /jobs/get_otel_traces/:id depends on.