Repository navigation
Trace Slack turn latency from message to reply #2269
Description
Objective
Measure how long one Slack message takes from send to Slack-accepted reply. Show the main steps in one Sentry trace: ingress, queue, worker, turn, model, tools, sandbox, and reply. Today we cannot see the time between webhook ingress and turn start, and we do not record model time to first chunk.
Current state (verified at 484112922)
- The trace ends at
slack.message.ingress. The queue message (task-execution/queue.ts) is a wake-up signal that carries onlyconversationId. No trace context crosses the mailbox, so worker spans start a new trace. - The queue callback and worker (
queue/callback.ts,task-execution/vercel-callback.ts,task-execution/worker.ts) emit no spans. They emit only info logs such asconversation.work.lease.acquired, and Sentry drops info logs in production. conversationWorkCallbackdiscards the VercelMessageMetadata(createdAt,deliveryCount).- Mailbox entries store
createdAtMs(Slack send time) andreceivedAtMs(webhook receipt). No telemetry records either value. gen_ai.chat(pi/traced-stream.ts) covers the full model call. It does not record time to first chunk.- First visible feedback (processing reaction, assistant status in
slack/assistant-thread/status-send.ts) records failures only. The reply post (slack/reply.ts) has no timing of its own. - Sandbox work already has spans:
sandbox.acquire,sandbox.reuse_probe,sandbox.get,sandbox.create,sandbox.snapshot.*,sandbox.sync_skills.sandbox.acquiredoes not record which path it took.
Constraint: phased rollout for stored fields
inboundMessageSchema (task-execution/state.ts) is .strict(). If one pending message fails to parse, readConversation throws InvalidConversationRecordError. The worker then rejects the wake as invalid_record, and that conversation's queued work is lost. An old worker that reads a new field during a deploy causes this failure.
Decided: every new mailbox field ships in two PRs. First, readers accept the optional field, and that change deploys to production. Then a second PR starts to write the field. Turn cursor fields don't need this: storedTurnRecordSchema uses .strip(), so old readers drop unknown fields instead of failing.
Plan
Each PR links this issue and adds its fields to TELEMETRY.md. Only 5a → 5b → 6 depend on each other. The other PRs can branch from main.
- feat(task-execution): Trace conversation work with queue lag and outcome #2283 — PR 1: worker span. Wrap
processConversationQueueMessage(the shared queue entry point;worker.tsis at its line limit) in aconversation.work.processspan. PassMessageMetadatathrough. Recordapp.queue.lag_ms,app.queue.delivery_count,app.worker.outcome, andapp.worker.pending_count. - feat(slack): Record mailbox input timing on chat.turn spans #2284 — PR 2: turn wait on
chat.turn. Recordapp.turn.delivery_lag_ms(receivedAtMs − createdAtMs),app.turn.wait_ms(chat.turnstart −receivedAtMs), andapp.turn.input_countonchat.turn. Use the oldest input message.wait_msends at thechat.turnspan start. - feat(task-execution): Use Sentry queue spans for conversation work #2320 — PR 1b: Sentry queue conventions. Add a
queue.publishspan around the send invercel-queue.ts. Changeconversation.work.processto opqueue.process. Renameapp.queue.lag_mstomessaging.message.receive.latencyandapp.queue.delivery_counttomessaging.message.retry.count. Always setmessaging.system=vercel_queueandmessaging.destination.name, so Slack log context values do not leak in. Merge before 5b. - feat(telemetry): Record time to first byte and first token on gen_ai.chat #2286 — PR 3: model TTFB and TTFT. In
pi/traced-stream.ts, relay the provider stream into a new stream that the agent loop reads, and time each event as the relay reads it. Recordapp.ai.time_to_first_byte_ms(first event, usuallystart),gen_ai.response.time_to_first_chunk(first content event),gen_ai.response.streaming, andapp.ai.first_token_type. - ref(slack): Trace assistant status calls for first feedback #2282 — PR 4: first feedback and reply accepted. Add an
http.clientspan aroundassistant.threads.setStatus, the one Slack feedback call that had no span. No first-feedback attributes: first feedback is the earliestreactions.addorassistant.threads.setStatusspan in the turn's trace. Noslack.reply.postspan: Junior's ownhttp.clientspan forchat.postMessagealready appears underchat.turn. The double processing reaction on mentions is tracked in Slack mention turns add the processing reaction twice #2318. - feat(task-execution): Accept optional trace context on inbound messages #2280 — PR 5a: mailbox accepts trace context (reader only). Add an optional, tolerant
traceContext: { sentryTrace, baggage }toinboundMessageSchema: malformed values are dropped, not rejected. Nothing writes it. Deploy to production before 5b merges. - feat(slack): Put Slack turns in the ingress trace #2406 — PR 5b: connect the trace. Ingress stores the active
sentry-traceandbaggageastraceContexton each Slack mailbox entry.chat.turnopens insidecontinueTracewith the oldest input's context, so it is a child ofslack.message.ingress. It links to the other inputs' ingress spans and to the worker (queue.process) span. With no or malformed context, the turn stays under the worker span. - feat(slack): Keep resumed Slack slices in the Turn trace #2408 — PR 6: resumed slices. The Turn cursor stores
traceContext(tolerant, like the mailbox field) instead oftraceId.checkpoint.tskeeps the first saved value. Every resume path (yield, timeout, OAuth, lost worker) openschat.turnin the original trace, as a child of the span that was active at the first save, with a link to the worker span. A single PR was enough: the cursor schema strips unknown fields. - feat(sandbox): Record sandbox acquire outcome on span #2281 — PR 7: sandbox acquire outcome. Set
app.sandbox.acquire.outcome(memory/resumed/created) onsandbox.acquire. Forcreated, the childsandbox.createspan says how it was created (app.sandbox.source,app.sandbox.snapshot.*). Derive per-turn sandbox cost from existing spans.
Merge order: #2280 (5a), #2283 (PR 1), #2284 (PR 2), #2282 (PR 4), #2320 (PR 1b), #2286 (PR 3), #2281 (PR 7), #2406 (5b, after #2280 reached production), #2408 (PR 6, stacked on 5b).
Decisions
- Trace shape: the turn joins the ingress trace through
continueTrace. - Model timing: record both time to first byte (first event of any kind) and time to first token (first content event), plus the content event type.
- Scope: Slack turns first.
- Telemetry never blocks work: malformed trace context is dropped, never a reason to reject a message.
- Resumed slice parent: a resumed
chat.turnis a child of the span that was active at the first save, not a sibling underslack.message.ingress. Keep this for now and adjust if traces show a need.
Related: #557 (sandbox egress span correlation).
via @thomasjiangcy.
--
Activity
fix(task-execution): Drop malformed inbound trace context
feat(slack): Record first feedback source on chat.turn
feat(telemetry): Record time to first byte and first token on gen_ai.…
ref(slack): Measure first feedback from the chat.turn span start
feat(slack): Measure turn wait to the chat.turn span start
feat(task-execution): Accept optional trace context on inbound messag…
feat(task-execution): Trace conversation work with queue lag and outc…
ref(slack): Return processing reaction result instead of a callback
ref(slack): Trace first feedback calls instead of recording attributes
ref(slack): Rename turn input hook to inputTelemetry
ref(slack): Rename turn telemetry hook to telemetry
feat(slack): Record mailbox input timing on chat.turn spans (#2284)
ref(slack): Trace Slack first feedback calls under chat.turn
3 remaining items
fix(telemetry): Import the relay stream from the pi-ai root
ref(telemetry): Pass the provider result through end() in the relay
feat(telemetry): Record time to first byte and first token on gen_ai.…
feat(task-execution): Use Sentry queue spans for conversation work (#…
ref(sandbox): Merge sandbox acquire create outcomes
feat(sandbox): Record sandbox acquire outcome on span (#2281)
feat(slack): Put Slack turns in the ingress trace
feat(slack): Put Slack turns in the ingress trace
feat(slack): Keep resumed Slack slices in the Turn trace
feat(slack): Keep resumed Slack slices in the Turn trace
feat(slack): Put Slack turns in the ingress trace
feat(slack): Keep resumed Slack slices in the Turn trace
feat(slack): Put Slack turns in the ingress trace (#2406)
feat(slack): Keep resumed Slack slices in the Turn trace (#2408)