Turn on Workflows in Observability for a project, and its workflow runs appear in the project’s own Observability with no code changes:
- Traces: each run is a trace, with spans for the run, its invocations, steps, sleeps,
callbacks and child workflows. Your own OpenTelemetry spans can nest under the invocation that
ran them, through
traceparent. - Metrics: counts and durations of runs, step attempts and invocations, queried with PromQL.
It’s off by default, and set per project. Runs and their steps never wait for Observability: while Observability is slow or unavailable, telemetry arrives late and nothing else changes.
Turn it on
The setting has two parts: whether it’s on, and the Observability environment the telemetry
goes to, production unless you choose another. The environment chooses the project’s
dataset, which is created on first use like any other.
Open Workflows, then Settings in the sidebar. In the Observability card, turn on Send runs to Observability, set the environment and save. Open Observability goes to the project’s Observability.
While it’s on, a run’s page has View trace, which opens the run’s trace in Observability.
ezgh workflows settings observability set --enabled --environment production --project web
ezgh workflows settings observability get --project web
ezgh workflows settings observability set --enabled=false --project web$ ezgh workflows settings observability get --project web
Enabled: yes
Environment: production
Updated: 2026-10-12T18:04:05.123Zset changes only what you pass: --enabled alone keeps the environment, and --environment
alone keeps whether it’s on. See
ezgh workflows settings observability.
| Operation | Request |
|---|---|
| GetObservabilitySettings | GET /v1/organizations/<organization ID>/projects/<project ID>/settings/observability |
| UpdateObservabilitySettings | PUT …/settings/observability |
{ "enabled": true, "environment": "production", "updatedAt": "2026-10-12T18:04:05.123Z" }- A project that never saved the setting answers
{ "enabled": false, "environment": "production", "updatedAt": null }. PUTtakes{ "enabled": true, "environment": "staging" }.enabledis required;environmentis optional, and an omitted one keeps the stored environment. It takes anIdempotency-Key.environmentis trimmed and lowercased, then must match^[a-z0-9][a-z0-9._-]{0,62}$. Otherwise, or with an unknown field, the answer is400 invalid_request.- Without
obs.traces.ingestorobs.metrics.ingest(see Permissions), turning it on is403 access_denied, naming the missing action. 409 settings_changedmeans someone changed the setting at the same time. Read it and try again.
A change takes effect within seconds, for runs already in progress too:
- Turned on mid-run: later spans arrive, with their parents’ IDs, even though the spans that ended before were never sent. The trace shows the spans from then on.
- Turned off: nothing more is sent, and new invocations no longer carry
traceparent. What was sent stays in Observability under the dataset’s retention.
Permissions
| Action | Allows | Checked against |
|---|---|---|
workflows.settings.get |
View the project’s Workflows settings | Project |
workflows.settings.update |
Change the project’s Workflows settings | Project |
Turning it on, or changing the environment while it’s on, sends telemetry into the project’s
Observability, so it also needs obs.traces.ingest and obs.metrics.ingest on the project, as
creating an ingest key does. Turning it off needs only
workflows.settings.update.
WorkflowsFullAccess allows both actions; WorkflowsReadOnlyAccess and ReadOnlyAccess allow
workflows.settings.get. Viewing the traces and metrics needs the usual Observability actions:
obs.traces.search, obs.traces.getSpans and obs.metrics.query.
Changing the setting is recorded in Trails as UpdateObservabilitySettings; reading it
isn’t. When the telemetry creates a dataset, its CreateDataset event is a service event by
workflows.ezghcloud.com.
Traces
A run’s trace ID is its run ID: the UUID’s 32 hex digits without hyphens. Search for it in the Traces page, or read it with Get a trace.
workflow.run <workflow> run created → ended, one per run attempt
├─ workflow.invocation invocation created → completed, failed or abandoned
│ ├─ (your spans) through traceparent
│ └─ workflow.step <name> step started → completed, retrying or failed, one per attempt
├─ workflow.sleep <name> sleep started → ended
├─ workflow.callback <name> callback created → completed or timed out
└─ workflow.invoke <name> child workflow started → ended- Retrying a run starts a new
workflow.runspan in the same trace, and each step attempt has its ownworkflow.stepspan. - A child workflow is a trace of its own (its own run ID). Its
workflow.runspan links to the parent’sworkflow.invokespan, and that span links to the child’s. - Spans are sent when they end, so a long run’s steps appear before its
workflow.runspan. - Status: a failed, timed-out or abandoned span, and a step attempt that’s retried, has
status
Error, withezgh.workflows.error_codeandezgh.workflows.error_name. A cancelled run’s span isUnset, withezgh.workflows.statuscancelled. Error messages, inputs and outputs are never sent.
| Attribute | On | Value |
|---|---|---|
ezgh.workflows.run_id |
Every span | The run ID |
ezgh.workflows.app |
Every span | The app’s name |
ezgh.workflows.workflow |
Every span | The workflow’s name |
ezgh.workflows.attempt |
Every span | The run attempt, or the step attempt on a step span |
ezgh.workflows.step |
Step, sleep, callback and invoke spans, and an invocation span that names a step | The step’s name |
ezgh.workflows.invocation_id |
Invocation and step spans | The invocation |
ezgh.workflows.invocation_kind |
Invocation spans | push or pull |
ezgh.workflows.trigger |
Run spans | api, schedule or invoke |
ezgh.workflows.schedule_id |
Run spans of a schedule’s runs | The schedule |
ezgh.workflows.status |
Run and invoke spans, and a timed-out callback’s span | completed, failed, cancelled or timedOut |
ezgh.workflows.outcome |
Invocation and step spans | completed, failed, retrying (steps) or abandoned (invocations) |
The resource of every span and metric has service.name ezgh.workflows,
ezgh.metadata.application set to the app’s name, and ezgh.metadata.environment set to the
setting’s environment.
Timing
Span times are when EZGH recorded each change, not when your code ran. The SDKs report a step’s result with the invocation’s next checkpoint, so a step can end later in its span than it did in your code, and a sleep or callback ends when the run resumes. Your own spans have exact times.
Metrics
| OTLP metric | Type | PromQL names | Labels |
|---|---|---|---|
workflows.runs.started |
Sum | workflows_runs_started_total |
app, workflow, trigger |
workflows.runs.ended |
Sum | workflows_runs_ended_total |
app, workflow, status |
workflows.run.duration |
Histogram, seconds | workflows_run_duration_seconds_bucket, …_sum, …_count |
app, workflow, status |
workflows.step.attempts |
Sum | workflows_step_attempts_total |
app, workflow, outcome |
workflows.step.duration |
Histogram, seconds | workflows_step_duration_seconds_bucket, …_sum, …_count |
app, workflow, outcome |
workflows.invocations |
Sum | workflows_invocations_total |
app, workflow, kind, outcome |
workflows.invocation.duration |
Histogram, seconds | workflows_invocation_duration_seconds_bucket, …_sum, …_count |
app, workflow, kind, outcome |
statusiscompleted,failed,cancelledortimedOut. A step’soutcomeiscompleted,retryingorfailed; an invocation’s iscompleted,failedorabandoned.kindispushorpull.triggerisapi,scheduleorinvoke.- Durations are of a run attempt, a step attempt, and an invocation from its creation. Histogram buckets run from 5 ms to 24 hours.
- Run IDs and step names are never labels, so the number of series depends only on your apps and workflows.
- Every series also has
environment,application(the app) andjobezgh.workflows; see Names and labels. The sums and histograms are delta, sorate,increaseandhistogram_quantileare exact.
Failed runs a second, by workflow:
sum by (workflow) (rate(workflows_runs_ended_total{status="failed"}[5m]))The 95th percentile of completed runs’ durations, by workflow:
histogram_quantile(0.95,
sum by (workflow, le) (rate(workflows_run_duration_seconds_bucket{status="completed"}[1h])))Step retries a minute, by app:
sum by (app) (increase(workflows_step_attempts_total{outcome="retrying"}[1m]))Your own spans: traceparent
While the setting is on, each invocation carries a W3C traceparent naming its
workflow.invocation span: push requests have a traceparent header, and pulled invocations a
traceparent field. The SDKs expose it as traceparent on the workflow’s context, an empty
string while the setting is off. Make it the parent of your spans to nest them under the
invocation. It needs SDK version 0.2.0 or later: TypeScript, Go and Python.
A push endpoint whose HTTP server is already instrumented with OpenTelemetry continues the trace from the request’s header by itself. Pull workers, and spans you start yourself, use the SDK’s value:
import { context, propagation, trace } from "@opentelemetry/api"
import { workflow } from "@ezgh/workflows"
const tracer = trace.getTracer("billing")
export const charge = workflow("charge", async ({ input, step, traceparent }) => {
// An empty traceparent leaves the context as it is.
const parent = propagation.extract(context.active(), { traceparent })
return step.run("charge-card", () =>
tracer.startActiveSpan("charge-card", {}, parent, async (span) => {
try {
return await payments.charge(input.orderId)
} finally {
span.end()
}
}),
)
})propagation.extract uses the registered propagator: the OpenTelemetry Node.js SDK registers W3C
Trace Context by default.
import (
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
workflows "github.com/ezgamehost/workflows-sdk-go"
)
var tracer = otel.Tracer("billing")
var charge = workflows.New("charge", func(ctx workflows.Context, in Order) (Receipt, error) {
carrier := propagation.MapCarrier{"traceparent": ctx.Run().Traceparent}
return workflows.Run(ctx, "charge-card", func(sc workflows.StepContext) (Receipt, error) {
// An empty traceparent leaves the context as it is.
spanCtx, span := tracer.Start(propagation.TraceContext{}.Extract(sc, carrier), "charge-card")
defer span.End()
return payments.Charge(spanCtx, in.OrderID)
})
})from opentelemetry import trace
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
from ezgh_workflows import Context, workflow
tracer = trace.get_tracer("billing")
@workflow("charge")
async def charge(ctx: Context) -> dict:
# An empty traceparent gives an empty context: the span starts a trace of its own.
parent = TraceContextTextMapPropagator().extract({"traceparent": ctx.traceparent})
def charge_card() -> dict:
with tracer.start_as_current_span("charge-card", context=parent):
return payments.charge(ctx.input["order_id"])
return await ctx.step.run("charge-card", charge_card)- Each invocation has its own
traceparent. Code outside steps runs again on every invocation, so a span started there appears under each invocation that ran it. - The SDKs don’t depend on OpenTelemetry: install and configure it in your application, with an ingest key sending to the same project and environment.
- While the setting is off, no
traceparentis sent, so your sampler never waits for a parent that won’t arrive.
Billing and limits
The telemetry is ingested like anything you send: its bytes and storage count in the Observability meters of the environment’s dataset, and it goes through the same rate limits, trace sampling and transform rules. Workflows’ own meters are unchanged.
Delivery
Telemetry is delivered at least once. After a timeout, a span or metric point can be sent again:
- A repeated span has the same trace and span IDs, so a trace can show the same span twice.
- A repeated metric point counts twice in
rateandincrease.
Telemetry older than 24 hours that couldn’t be delivered, because Observability was unavailable or the project’s limits refused it, isn’t sent.