Skip to main content

Job Tracing

Warp automatically tracks the flow of jobs across handlers. When a job handler spawns new jobs, they share a TraceId, making the full execution chain visible in the dashboard.

How It Works

Every job gets two trace fields:

  • TraceId — All related jobs share this ID. The first job in a flow creates it (TraceId = own ID). All spawned jobs inherit it.
  • SpawnedByJobId — Direct "who created me" link.

This happens automatically via AsyncLocal context. When a handler calls publisher.Enqueue() or batchPublisher.StartNew(), the new jobs inherit the trace.

Example

public class ProcessOrderHandler : IJobHandler<ProcessOrderRequest>
{
private readonly IBatchPublisher _batchPublisher;

public async Task HandleAsync(ProcessOrderRequest message, CancellationToken ct)
{
// These jobs automatically inherit the trace from ProcessOrderRequest
var shipItems = items.Select(i => new ShipItemRequest { ItemId = i.Id }).ToList();
var batchId = await _batchPublisher.StartNew(shipItems);

// Continuation also inherits the trace
await _batchPublisher.ContinueBatchWith(
new List<SendInvoiceRequest> { new() { OrderId = message.OrderId } },
batchId);
}
}

The dashboard shows the full trace:

The "Trace (9 jobs)" card shows all jobs spawned from this ProcessOrderRequest: 6 ShipItemRequests and 2 PublishInvoiceRequests. Clicking any job navigates to its detail, which shows the same trace from that job's perspective.

Clicking the trace link opens a dedicated visualization page showing the full DAG:

Message-Routed Jobs

When a message is routed to multiple handlers, all resulting jobs share a TraceId:

await publisher.Publish(new OrderNotification()); // Routes to EmailHandler + SlackHandler
// Both jobs get the same TraceId

Unified trace view

The dashboard's /trace/{traceId} page shows everything that happened for one trace on a single screen — not just the jobs, but the browser request, the server endpoint it hit, the jobs that endpoint spawned, and the outbound adapter calls those jobs made.

The insight is that Warp already stores spans. Each of these rows carries a trace id, a start time, and (mostly) a duration:

SourceRowIs a span because…
ClientClientEventLog (Type = Request)trace id + timestamp + client-measured duration
ServerEndpointCallLogtrace id + timestamp + duration + outcome
JobJobtrace id + create time + terminal state (parent = SpawnedByJobId)
OutboundAdapterCallLogtrace id + timestamp + duration + outcome

So the unified trace view is built by unioning those existing rows on their shared trace id — there is no separate span table, no trace collector, and nothing added to the worker hot path. It's a local, DB-backed view over data Warp persists anyway.

The page renders two complementary things:

  • A waterfall (Gantt) across all four sources on one shared time axis — client (green) → server (slate) → jobs (blue) → outbound (purple) — with error bars highlighted and each span linking to its own detail page. This is the new cross-source timeline.
  • The existing job graph (the parent/child DAG below the waterfall), which shows how jobs spawned each other.

Job bars get their execution window from the job's terminal log (the worker records the handler's duration on completion), so a job nests over the outbound calls it made. A job whose logs have aged out under retention, or that hasn't finished yet, falls back to a placeholder marker (). The waterfall is reachable from the session timeline and from any job, endpoint, or adapter detail page.

Served by GET {prefix}/api/traces/{traceId} (distinct from the job-DAG endpoint that backs the graph). Because it reads local rows, it gives you a near-Jaeger experience on your own database; when your data outgrows the DB, point an external collector at Warp's always-on OTel spans (below) instead.

OpenTelemetry Integration

Warp produces OTel-standard distributed traces and metrics using System.Diagnostics. Everything is on by default with zero configuration.

Distributed Tracing

Every job execution creates a System.Diagnostics.Activity with:

  • TraceId — matches the job's database TraceId
  • SpanId — unique per execution (new SpanId on retries)
  • ParentSpanId — the SpanId of whoever enqueued this job (HTTP request, another handler, etc.)

This creates a proper trace tree across job chains:

HTTP Request (TraceId: T, SpanId: A)
└── Enqueue(ProcessOrder) → Activity(TraceId: T, SpanId: B, ParentId: A)
└── Enqueue(ShipItem) → Activity(TraceId: T, SpanId: C, ParentId: B)
└── Enqueue(Notify) → Activity(TraceId: T, SpanId: D, ParentId: C)

Trace context is automatically propagated:

  • When a handler calls publisher.Enqueue(), the child job captures the handler's SpanId
  • When a message is routed to multiple handlers, all child jobs inherit the publisher's span
  • Batch children inherit the same parent span

Log Correlation

AddWarpServer automatically configures ActivityTrackingOptions so TraceId, SpanId, and ParentId appear in your log output:

info: MyApp.Handlers.SendReport[0]
=> SpanId:b7ad6b7169203331, TraceId:550e8400e29b41d4a716446655440000, ParentId:a1b2c3d4e5f60718
Sending report to user 42

No configuration needed — this works with the built-in console logger and any provider that supports scopes.

Span Attributes

Each job execution span includes these tags:

AttributeExampleDescription
messaging.system"warp"OTel semantic convention
messaging.operation.name"process"OTel semantic convention
messaging.destination.name"default"Queue the job belongs to
messaging.message.id"550e8400-..."Job ID
warp.job.type"MyApp.SendReport".NET type name
warp.job.kind"Job"Job, Message, or Batch
warp.job.status"succeeded"Set after execution: succeeded, failed, retried, cancelled
warp.job.duration_ms142.5Handler execution time (on success)
warp.job.retry_count2Current retry count (only if retried)

On failure, Activity.SetStatus(Error) is called with the exception message.

Span Events

Key lifecycle moments are recorded as events on the span:

EventWhenAttributes
warp.job.completedHandler succeedsduration_ms
warp.job.failedHandler throws (no retries left)exception.type, exception.message
warp.job.retriedHandler throws (will retry)retry_count, max_retries
warp.job.cancelledJob cancelled while running

Metrics

Warp exposes four metrics through a System.Diagnostics.Metrics.Meter named "Warp":

MetricTypeUnitTagsDescription
warp.job.durationHistogrammsqueue, type, statusHandler execution time
warp.job.activeUpDownCounter{job}queueCurrently processing jobs
warp.job.completedCounter{job}queue, type, statusJobs that finished processing
warp.job.enqueuedCounter{job}queue, kindJobs enqueued

The status tag is one of: succeeded, failed, retried, cancelled. The kind tag is one of: job, message, batch.

Exporting to OTel Backends

To export traces and metrics to Jaeger, Prometheus, Datadog, etc., subscribe to the "Warp" source and meter:

builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource("Warp"))
.WithMetrics(m => m.AddMeter("Warp"));

Without this, traces still appear in logs (via ActivityTrackingOptions) and metric calls are silent no-ops — no overhead.