@agent-native/core 0.172.3 → 0.172.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/calendar/actions/get-settings.ts +2 -5
  3. package/corpus/templates/calendar/actions/list-events.ts +21 -80
  4. package/corpus/templates/calendar/actions/update-settings.ts +11 -16
  5. package/corpus/templates/calendar/actions/view-screen.ts +25 -10
  6. package/corpus/templates/calendar/app/components/calendar/CommandPalette.tsx +7 -1
  7. package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +5 -2
  8. package/corpus/templates/calendar/app/lib/calendar-timezone.ts +3 -18
  9. package/corpus/templates/calendar/app/lib/event-form-utils.ts +3 -62
  10. package/corpus/templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md +6 -0
  11. package/corpus/templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md +6 -0
  12. package/corpus/templates/calendar/server/handlers/settings.ts +8 -24
  13. package/corpus/templates/calendar/server/lib/calendar-settings.ts +58 -0
  14. package/corpus/templates/calendar/server/lib/find-time.ts +15 -93
  15. package/corpus/templates/calendar/shared/settings.ts +15 -9
  16. package/corpus/templates/calendar/shared/timezone.ts +127 -0
  17. package/corpus/templates/slides/actions/list-decks.ts +79 -5
  18. package/corpus/templates/slides/app/components/deck/DeckCard.tsx +21 -1
  19. package/corpus/templates/slides/app/components/editor/ShareDialog.tsx +49 -21
  20. package/corpus/templates/slides/app/context/DeckContext.tsx +49 -21
  21. package/corpus/templates/slides/app/pages/DeckEditor.tsx +2 -2
  22. package/corpus/templates/slides/app/pages/Index.tsx +11 -13
  23. package/corpus/templates/slides/changelog/2026-08-24-deck-previews-and-sharing-live-in-the-overflow-menu.md +5 -0
  24. package/dist/agent/production-agent.js +5 -1
  25. package/dist/agent/types.d.ts +5 -0
  26. package/dist/collab/awareness.d.ts +2 -2
  27. package/dist/file-upload/actions/upload-image.d.ts +1 -1
  28. package/dist/observability/posthog-ai.d.ts +36 -11
  29. package/dist/observability/posthog-ai.js +99 -30
  30. package/dist/observability/routes.d.ts +3 -3
  31. package/dist/observability/routes.js +9 -2
  32. package/dist/observability/traces.js +330 -142
  33. package/dist/progress/routes.d.ts +1 -1
  34. package/dist/resources/handlers.d.ts +1 -1
  35. package/dist/server/realtime-token.d.ts +1 -1
  36. package/dist/server/transcribe-voice.d.ts +1 -1
  37. package/dist/tracking/providers.js +9 -3
  38. package/docs/content/observability.mdx +27 -10
  39. package/package.json +3 -3
@@ -1,6 +1,6 @@
1
1
  import { captureError } from "../server/capture-error.js";
2
2
  import { getRequestContext } from "../server/request-context.js";
3
- import { MAX_AI_CONTENT_BYTES, MAX_AI_SPANS_PER_RUN, boundAiContent, emitAiSpanEvent, emitAiTraceEvent, toAiErrorDetail, } from "./posthog-ai.js";
3
+ import { MAX_AI_CONTENT_BYTES, MAX_AI_SPANS_PER_RUN, boundAiContent, emitAiSpanEvent, emitAiTraceEvent, resolveAiError, toAiErrorDetail, } from "./posthog-ai.js";
4
4
  import { endAgentSpan, startAgentSpan } from "./tracing.js";
5
5
  import { trackingIdentityProperties } from "./tracking-identity.js";
6
6
  import { DEFAULT_OBSERVABILITY_CONFIG } from "./types.js";
@@ -143,6 +143,7 @@ function emitLlmGenerationTrackingEvent(args) {
143
143
  cost_cents_x100: args.costCentsX100,
144
144
  cost_usd: costUsd,
145
145
  duration_ms: args.durationMs,
146
+ stop_reason: args.stopReason,
146
147
  time_to_first_token_ms: args.firstTokenMs,
147
148
  status: args.status,
148
149
  tool_calls: args.toolCalls,
@@ -177,23 +178,24 @@ function emitLlmGenerationTrackingEvent(args) {
177
178
  $ai_output_tokens: args.outputTokens,
178
179
  $ai_latency: Math.round(args.llmDurationMs) / 1000,
179
180
  $ai_is_error: args.status === "error",
180
- $ai_error: args.status === "error"
181
- ? toAiErrorDetail(error, {
182
- state: args.terminalOutcome?.state,
183
- code: terminalCode,
184
- retryable: terminalRetryable,
185
- })
186
- : undefined,
181
+ $ai_error: resolveAiError(args.status === "error", toAiErrorDetail(error, {
182
+ state: args.terminalOutcome?.state,
183
+ code: terminalCode,
184
+ retryable: terminalRetryable,
185
+ })),
186
+ // A generation fails only as the model layer, so the default kind says so;
187
+ // a classified terminal code names it more precisely.
188
+ $ai_error_type: args.status === "error" ? (terminalCode ?? "llm_error") : undefined,
187
189
  // Every engine here streams (`messages.stream`, `streamText`, gateway SSE),
188
190
  // which is also what makes `$ai_time_to_first_token` meaningful.
189
191
  $ai_stream: true,
190
192
  $ai_cache_read_input_tokens: args.cacheReadTokens,
191
193
  $ai_cache_creation_input_tokens: args.cacheWriteTokens,
192
194
  $ai_request_count: args.llmCallCount,
195
+ $ai_stop_reason: args.stopReason,
193
196
  $ai_total_cost_usd: costUsd,
194
197
  $ai_input: args.aiInput,
195
198
  $ai_output_choices: args.aiOutputChoices,
196
- $ai_tools: args.aiTools,
197
199
  input_truncated: args.aiInputTruncated || undefined,
198
200
  output_truncated: args.aiOutputTruncated || undefined,
199
201
  latency_source: args.llmDurationMeasured ? "measured" : "derived",
@@ -237,18 +239,22 @@ function emitLlmGenerationTrackingEvent(args) {
237
239
  }
238
240
  }
239
241
  /**
240
- * Build the PostHog content fields for a run's `$ai_generation`.
242
+ * Build the PostHog content fields for one `$ai_generation`.
241
243
  *
242
- * This is one generation per run carrying the run's whole message list, not one
243
- * per model round-trip: the engine layer reports aggregate usage only
244
- * (`onUsage`) and exposes no per-step hook. Consequence to know when reading a
245
- * trace — per-round-trip latency and intermediate assistant turns are not
246
- * visible; a multi-step run collapses into a single generation node.
244
+ * One generation per model round-trip: `messages` is what that call was sent,
245
+ * `assistantText` what it answered, and `toolSpans` the tools it then asked
246
+ * for. An engine that never brackets its calls with `model_stream` has no
247
+ * round-trips to split on and falls back to a single generation covering the
248
+ * whole run — an aggregate, and reported as one.
247
249
  *
248
250
  * `$ai_output_choices` is emitted whenever tool calls happened even with
249
251
  * `capturePrompts` off, because PostHog derives `$ai_tools_called` /
250
252
  * `$ai_tool_call_count` from tool-call blocks inside it and nothing else. The
251
253
  * assistant's text content stays gated; only the structural call list ships.
254
+ *
255
+ * The app's tool DEFINITIONS are not sent at all. They are the same catalogue
256
+ * on every call — tens of kilobytes of descriptions — and a call is already
257
+ * identified by its name in `tool_calls` and by its own span.
252
258
  */
253
259
  function buildGenerationContent(args) {
254
260
  const { config } = args;
@@ -285,26 +291,9 @@ function buildGenerationContent(args) {
285
291
  },
286
292
  ])
287
293
  : undefined;
288
- const toolList = Array.isArray(args.tools)
289
- ? args.tools
290
- .filter((tool) => !!tool && typeof tool.name === "string")
291
- .map((tool) => ({
292
- type: "function",
293
- function: {
294
- name: tool.name,
295
- ...(typeof tool.description === "string"
296
- ? { description: tool.description }
297
- : {}),
298
- },
299
- }))
300
- : [];
301
294
  return {
302
295
  aiInput: input?.value,
303
296
  aiOutputChoices: output?.value,
304
- // Tool definitions are app configuration rather than user content, so they
305
- // are not gated — without them a trace shows calls to tools nobody can
306
- // identify. Schemas are excluded to keep the event small.
307
- aiTools: toolList.length ? toolList : undefined,
308
297
  aiInputTruncated: input?.truncated,
309
298
  aiOutputTruncated: output?.truncated,
310
299
  };
@@ -408,13 +397,31 @@ export async function instrumentAgentLoop(opts) {
408
397
  let toolCallCount = 0;
409
398
  let successfulTools = 0;
410
399
  let failedTools = 0;
400
+ /** Tools that reported a failure of their own, excluding the ones the run's
401
+ * death interrupted — those are a consequence of the failure, never
402
+ * evidence of what caused it. */
403
+ let reportedToolFailures = 0;
411
404
  // One `model_stream` start/end bracket is emitted per LLM round-trip, and it
412
405
  // closes before any tool of that turn is started — so these intervals ARE the
413
- // model's wall clock, not an estimate of it. Recording them is what lets the
406
+ // model's wall clock, not an estimate of it. Recording them is what lets each
414
407
  // generation report a measured `$ai_latency` instead of backing tool time out
415
- // of the run duration.
408
+ // of the run duration, and what makes a round-trip the unit PostHog draws:
409
+ // one `$ai_generation` per model call, with that call's tools underneath it.
410
+ // Engines that never bracket their calls record none, and the run falls back
411
+ // to a single aggregate generation.
412
+ const modelRoundTrips = [];
413
+ /** The call currently streaming, or the last one that streamed — text and
414
+ * usage arriving between calls belong to the call that just finished. */
415
+ const currentRoundTrip = () => modelRoundTrips[modelRoundTrips.length - 1];
416
416
  const modelStreamIntervals = [];
417
417
  let modelStreamOpenedAt = null;
418
+ /** Tool span id → the round-trip that requested it. */
419
+ const toolSpanRoundTrip = new Map();
420
+ /** Tool invocation counter → the same, for the generation's `tools` list. */
421
+ const toolCounterRoundTrip = new Map();
422
+ /** Tool span id → how it failed. A class, not the tool's output, so it
423
+ * travels even when `captureToolResults` withholds the message. */
424
+ const toolSpanErrorClass = new Map();
418
425
  // Track in-flight OTel tool spans so they're all ended even if the loop
419
426
  // throws before a matching `tool_done` arrives.
420
427
  const openOtelToolSpans = new Set();
@@ -463,6 +470,7 @@ export async function instrumentAgentLoop(opts) {
463
470
  assistantTextLength < MAX_AI_CONTENT_BYTES) {
464
471
  assistantTextParts.push(event.text);
465
472
  assistantTextLength += event.text.length;
473
+ currentRoundTrip()?.assistantText.push(event.text);
466
474
  }
467
475
  // Some guardrails intentionally stop the loop by emitting a terminal
468
476
  // event and returning usage instead of throwing. Preserve that terminal
@@ -502,19 +510,54 @@ export async function instrumentAgentLoop(opts) {
502
510
  // The emitter brackets these itself, so a repeated start or an
503
511
  // unmatched end is a no-op here rather than a fabricated interval.
504
512
  if (event.status === "start") {
505
- modelStreamOpenedAt ??= Date.now();
513
+ if (modelStreamOpenedAt === null) {
514
+ modelStreamOpenedAt = Date.now();
515
+ modelRoundTrips.push({
516
+ spanId: spanId(),
517
+ start: modelStreamOpenedAt,
518
+ end: modelStreamOpenedAt,
519
+ // Copied: the loop appends this call's answer and its tool
520
+ // results to the same array as the run continues, so a reference
521
+ // held here would report the whole transcript as this call's
522
+ // prompt.
523
+ //
524
+ // This is the loop's message list, which is not byte-for-byte
525
+ // what the engine received: Context X-Ray, observational memory
526
+ // and overflow trimming build a separate `contextMessages` for
527
+ // the provider. Reporting a prompt the model never saw is a
528
+ // known gap, and closing it needs the loop to hand its
529
+ // per-call request to instrumentation.
530
+ ...(config.capturePrompts
531
+ ? { input: [...loopOpts.messages] }
532
+ : {}),
533
+ assistantText: [],
534
+ });
535
+ }
506
536
  }
507
537
  else if (modelStreamOpenedAt !== null) {
508
- modelStreamIntervals.push({
509
- start: modelStreamOpenedAt,
510
- end: Date.now(),
511
- });
538
+ const end = Date.now();
539
+ modelStreamIntervals.push({ start: modelStreamOpenedAt, end });
540
+ const trip = currentRoundTrip();
541
+ if (trip) {
542
+ trip.end = end;
543
+ if (event.reason)
544
+ trip.stopReason = event.reason;
545
+ }
512
546
  modelStreamOpenedAt = null;
513
547
  }
514
548
  }
515
549
  if (event.type === "tool_start") {
516
550
  const counter = toolInvocationCounter++;
517
551
  const sid = spanId();
552
+ // Recorded here, not at `tool_done`: a tool the run's death interrupts
553
+ // never reaches that path, and would then hang under the trace root
554
+ // instead of the call that asked for it. The model_stream bracket
555
+ // closes before the turn's tools start, so the last round-trip is the
556
+ // requesting call.
557
+ if (modelRoundTrips.length > 0) {
558
+ toolSpanRoundTrip.set(sid, modelRoundTrips.length - 1);
559
+ toolCounterRoundTrip.set(counter, modelRoundTrips.length - 1);
560
+ }
518
561
  // Start the OTel tool span synchronously-ish: kick off the async
519
562
  // resolution and stash the span once it lands. Tool spans are short
520
563
  // and the api tracer is synchronous in practice, but we tolerate the
@@ -589,8 +632,10 @@ export async function instrumentAgentLoop(opts) {
589
632
  : typeof event.result === "string" &&
590
633
  (event.result.startsWith("Error") ||
591
634
  event.result.startsWith("Error running "));
592
- if (isError)
635
+ if (isError) {
593
636
  failedTools++;
637
+ reportedToolFailures++;
638
+ }
594
639
  else
595
640
  successfulTools++;
596
641
  if (counter !== undefined &&
@@ -647,8 +692,14 @@ export async function instrumentAgentLoop(opts) {
647
692
  const spanMetadata = Object.keys(spanMetadataFields).length
648
693
  ? spanMetadataFields
649
694
  : null;
695
+ const toolSpanId = pending?.spanId ?? spanId();
696
+ // The model_stream bracket closes before the turn's tools start, so the
697
+ // last recorded round-trip is the call that requested this one.
698
+ if (isError) {
699
+ toolSpanErrorClass.set(toolSpanId, explicitError ? "tool_error" : "legacy_inferred_error");
700
+ }
650
701
  const span = {
651
- id: pending?.spanId ?? spanId(),
702
+ id: toolSpanId,
652
703
  runId,
653
704
  threadId,
654
705
  userId,
@@ -675,12 +726,30 @@ export async function instrumentAgentLoop(opts) {
675
726
  catch { }
676
727
  loopOpts.send(event);
677
728
  };
729
+ // The loop appends to this array in place — its own assistant turns, tool
730
+ // results, internal continuation prompts. Read after the run it is the final
731
+ // transcript, not the request, so the model's reply showed up inside
732
+ // `$ai_input` as well as `$ai_output_choices`. Snapshot the array before the
733
+ // loop can grow it. The message objects stay shared on purpose: the last user
734
+ // message is enriched in place (screen context, @-mention responses), and the
735
+ // enriched text is what the model actually received.
736
+ const requestMessages = Array.isArray(loopOpts.messages)
737
+ ? [...loopOpts.messages]
738
+ : loopOpts.messages;
678
739
  try {
679
740
  usage = await runAgentLoop({
680
741
  ...loopOpts,
681
742
  runId,
682
743
  send: instrumentedSend,
683
744
  onOutcome: instrumentedOutcome,
745
+ // Fires once per model round-trip with THAT call's tokens, not the
746
+ // running total — which is what lets each generation report its own.
747
+ onUsage: (callUsage) => {
748
+ const trip = currentRoundTrip();
749
+ if (trip)
750
+ trip.usage = callUsage;
751
+ loopOpts.onUsage?.(callUsage);
752
+ },
684
753
  });
685
754
  }
686
755
  catch (err) {
@@ -711,8 +780,12 @@ export async function instrumentAgentLoop(opts) {
711
780
  // The loop threw or was killed mid-stream, so no `end` ever arrived. The
712
781
  // model was still running when the run stopped, so the interval closes at
713
782
  // the run's end rather than being dropped.
783
+ const failedInsideModelCall = modelStreamOpenedAt !== null;
714
784
  if (modelStreamOpenedAt !== null) {
715
785
  modelStreamIntervals.push({ start: modelStreamOpenedAt, end: runEnd });
786
+ const trip = currentRoundTrip();
787
+ if (trip)
788
+ trip.end = runEnd;
716
789
  modelStreamOpenedAt = null;
717
790
  }
718
791
  // Undefined means the engine never bracketed its model calls, NOT that
@@ -730,6 +803,7 @@ export async function instrumentAgentLoop(opts) {
730
803
  toolCallCount += 1;
731
804
  failedTools += 1;
732
805
  const interruptedMessage = "Tool call interrupted before completion";
806
+ toolSpanErrorClass.set(pending.spanId, "interrupted");
733
807
  if (counter < MAX_TRACKED_GENERATION_TOOL_CALLS) {
734
808
  generationToolCalls.set(counter, {
735
809
  name: pending.toolName,
@@ -781,8 +855,11 @@ export async function instrumentAgentLoop(opts) {
781
855
  toolCallIdToCounter.clear();
782
856
  }
783
857
  let costCentsX100 = 0;
858
+ // Held for the per-generation costs below, which price each round-trip
859
+ // from its own tokens rather than splitting the run total.
860
+ let calculateCost;
784
861
  try {
785
- const { calculateCost } = await import("../usage/store.js");
862
+ ({ calculateCost } = await import("../usage/store.js"));
786
863
  if (usage) {
787
864
  costCentsX100 = calculateCost(usage.inputTokens, usage.outputTokens, usage.model, usage.cacheReadTokens, usage.cacheWriteTokens);
788
865
  }
@@ -813,6 +890,15 @@ export async function instrumentAgentLoop(opts) {
813
890
  const emittedToolSpans = (config.captureLlmSpans ? collectedToolSpans : []).slice(0, MAX_AI_SPANS_PER_RUN);
814
891
  const droppedToolSpans = (config.captureLlmSpans ? collectedToolSpans.length : 0) -
815
892
  emittedToolSpans.length;
893
+ // Elapsed to the run's first engine event. Belongs to the run, and is
894
+ // reported on its first generation as PostHog's per-call field.
895
+ const runFirstTokenMs = usage?.firstEngineEventAtMs !== undefined
896
+ ? Math.max(0, usage.firstEngineEventAtMs - runStart)
897
+ : undefined;
898
+ const modelCallFailed = failedInsideModelCall ||
899
+ (modelRoundTrips.length === 0 &&
900
+ cutOffReason === null &&
901
+ reportedToolFailures === 0);
816
902
  let llmCallCount = 0;
817
903
  if (usage || runStatus === "error") {
818
904
  llmCallCount =
@@ -820,7 +906,7 @@ export async function instrumentAgentLoop(opts) {
820
906
  // Compatibility for custom loop implementations that predate the
821
907
  // attempt counter: a measured run still counts as one call.
822
908
  1;
823
- const generationUsage = usage ?? {
909
+ const runUsage = usage ?? {
824
910
  inputTokens: 0,
825
911
  outputTokens: 0,
826
912
  cacheReadTokens: 0,
@@ -829,15 +915,13 @@ export async function instrumentAgentLoop(opts) {
829
915
  };
830
916
  // The engine never reported a `usage` event for this run (killed for
831
917
  // silence before any provider response, or the loop threw before
832
- // returning). `generationUsage`'s token fields are placeholder zeros in
833
- // that case, not measured values — the tracking event below must omit
834
- // them rather than report a fabricated 0.
918
+ // returning). `runUsage`'s token fields are placeholder zeros in that
919
+ // case, not measured values — the tracking events below must omit them
920
+ // rather than report a fabricated 0.
835
921
  const usageReported = usage?.usageReported === true;
836
- const firstTokenMs = usage?.firstEngineEventAtMs !== undefined
837
- ? Math.max(0, usage.firstEngineEventAtMs - runStart)
922
+ const engineName = typeof loopOpts.engine?.name === "string"
923
+ ? loopOpts.engine.name
838
924
  : undefined;
839
- const llmSpanId = spanId();
840
- const generationToolSpans = collectedToolSpans;
841
925
  // Measured model time when the engine bracketed its round-trips.
842
926
  //
843
927
  // The fallback backs tool time out of the run instead, which is an
@@ -845,79 +929,164 @@ export async function instrumentAgentLoop(opts) {
845
929
  // skip tools PostHog will not receive, and clamp at zero. Engines that
846
930
  // report `model_stream` need none of that, so `latency_source` records
847
931
  // which of the two a given `$ai_latency` came from.
848
- const llmDurationMs = measuredModelDurationMs ??
932
+ const derivedLlmDurationMs = measuredModelDurationMs ??
849
933
  Math.max(0, totalDurationMs -
850
934
  coveredDurationMs(spanIntervals(emittedToolSpans)));
851
- const generationContent = buildGenerationContent({
852
- config,
853
- messages: loopOpts.messages,
854
- tools: loopOpts.tools,
855
- assistantText: assistantTextParts.join(""),
856
- toolSpans: generationToolSpans,
857
- });
858
- const llmSpan = {
859
- id: llmSpanId,
860
- runId,
861
- threadId,
862
- userId,
863
- parentSpanId,
864
- spanType: "llm_call",
865
- name: generationUsage.model,
866
- inputTokens: generationUsage.inputTokens,
867
- outputTokens: generationUsage.outputTokens,
868
- cacheReadTokens: generationUsage.cacheReadTokens,
869
- cacheWriteTokens: generationUsage.cacheWriteTokens,
870
- costCentsX100,
871
- durationMs: totalDurationMs,
872
- status: runStatus,
873
- errorMessage,
874
- metadata: null,
875
- createdAt: runStart,
876
- };
877
- spans.push(llmSpan);
878
- emitLlmGenerationTrackingEvent({
879
- runId,
880
- threadId,
881
- userId,
882
- parentSpanId,
883
- llmSpanId,
884
- engineName: typeof loopOpts.engine?.name === "string"
885
- ? loopOpts.engine.name
886
- : undefined,
887
- model: generationUsage.model,
888
- inputTokens: usageReported ? generationUsage.inputTokens : undefined,
889
- outputTokens: usageReported
890
- ? generationUsage.outputTokens
891
- : undefined,
892
- cacheReadTokens: usageReported
893
- ? generationUsage.cacheReadTokens
894
- : undefined,
895
- cacheWriteTokens: usageReported
896
- ? generationUsage.cacheWriteTokens
897
- : undefined,
898
- costCentsX100: usageReported ? costCentsX100 : undefined,
899
- durationMs: totalDurationMs,
900
- llmDurationMs,
901
- llmDurationMeasured: measuredModelDurationMs !== undefined,
902
- llmCallCount,
903
- firstTokenMs,
904
- status: runStatus,
905
- errorMessage,
906
- toolCalls: toolCallCount,
907
- successfulTools,
908
- failedTools,
909
- tools: [...generationToolCalls.entries()]
910
- .sort(([a], [b]) => a - b)
911
- .map(([, detail]) => detail),
912
- toolsTruncated: toolInvocationCounter > MAX_TRACKED_GENERATION_TOOL_CALLS,
913
- terminalOutcome: effectiveTerminalOutcome,
914
- delegation: opts.delegation,
915
- createdAt: runStart,
916
- experimentAssignments: opts.experimentAssignments,
917
- modelSelectionSource: opts.modelSelectionSource,
918
- browserSessionId,
919
- ...generationContent,
920
- });
935
+ // One generation per model round-trip, so a trace reads as the run
936
+ // actually happened: call, its tools, next call. An engine that never
937
+ // brackets its calls records no round-trips and falls back to a single
938
+ // generation covering the run — an aggregate, and visibly one.
939
+ const generations = modelRoundTrips.length > 0
940
+ ? modelRoundTrips.map((trip, index) => ({
941
+ spanId: trip.spanId,
942
+ model: trip.usage?.model ?? runUsage.model,
943
+ createdAt: trip.start,
944
+ latencyMs: Math.max(0, trip.end - trip.start),
945
+ callUsage: trip.usage,
946
+ stopReason: trip.stopReason,
947
+ // The engine reported this call's usage as the call happened,
948
+ // so its presence IS the report — the loop's aggregate return
949
+ // value never arrives when a later call throws, and gating on
950
+ // it dropped the tokens of every call that had succeeded.
951
+ tokensKnown: trip.usage !== undefined,
952
+ input: trip.input,
953
+ assistantText: trip.assistantText.join(""),
954
+ toolSpans: collectedToolSpans.filter((span) => toolSpanRoundTrip.get(span.id) === index),
955
+ toolDetails: [...generationToolCalls.entries()]
956
+ .filter(([counter]) => toolCounterRoundTrip.get(counter) === index)
957
+ .sort(([a], [b]) => a - b)
958
+ .map(([, detail]) => detail),
959
+ isFirst: index === 0,
960
+ // Only the last call can carry the run's failure: an earlier
961
+ // one that had failed would have ended the run there.
962
+ isLast: index === modelRoundTrips.length - 1,
963
+ }))
964
+ : [
965
+ {
966
+ spanId: spanId(),
967
+ model: runUsage.model,
968
+ createdAt: runStart,
969
+ latencyMs: derivedLlmDurationMs,
970
+ callUsage: usage,
971
+ stopReason: undefined,
972
+ tokensKnown: usageReported,
973
+ input: requestMessages,
974
+ assistantText: assistantTextParts.join(""),
975
+ toolSpans: collectedToolSpans,
976
+ toolDetails: [...generationToolCalls.entries()]
977
+ .sort(([a], [b]) => a - b)
978
+ .map(([, detail]) => detail),
979
+ isFirst: true,
980
+ isLast: true,
981
+ },
982
+ ];
983
+ for (const generation of generations) {
984
+ const callUsage = generation.callUsage;
985
+ let callCostCentsX100;
986
+ if (calculateCost && callUsage && generation.tokensKnown) {
987
+ try {
988
+ callCostCentsX100 = calculateCost(callUsage.inputTokens, callUsage.outputTokens, callUsage.model, callUsage.cacheReadTokens, callUsage.cacheWriteTokens);
989
+ // Cost estimation is enrichment: a pricing-table miss leaves the
990
+ // generation without a cost rather than failing the trace.
991
+ }
992
+ catch { } // coercion-ok: see above
993
+ }
994
+ // `$ai_is_error` on a generation means the MODEL call failed —
995
+ // a provider error, a dropped stream, an SDK throw. A tool that
996
+ // aborted the run, a step budget or a cut-off is the trace's failure
997
+ // (and the tool span's), and painting the last generation red hides
998
+ // which layer actually broke.
999
+ //
1000
+ // Two ways to know it was the model: the run died with this call's
1001
+ // stream still open, or the engine never bracketed its calls at all
1002
+ // and nothing else can account for the failure — no run boundary, no
1003
+ // failed tool.
1004
+ const generationStatus =
1005
+ // The engine reported this call itself as failed. Its bracket
1006
+ // closes on the way out, so waiting for an open stream at
1007
+ // finalization would report a provider error as a healthy call —
1008
+ // and a later retry would leave the failed attempt green.
1009
+ generation.stopReason === "error" ||
1010
+ (generation.isLast && runStatus === "error" && modelCallFailed)
1011
+ ? "error"
1012
+ : "success";
1013
+ const generationError = generationStatus === "error" ? errorMessage : null;
1014
+ const generationContent = buildGenerationContent({
1015
+ config,
1016
+ messages: generation.input,
1017
+ assistantText: generation.assistantText,
1018
+ toolSpans: generation.toolSpans,
1019
+ });
1020
+ spans.push({
1021
+ id: generation.spanId,
1022
+ runId,
1023
+ threadId,
1024
+ userId,
1025
+ parentSpanId,
1026
+ spanType: "llm_call",
1027
+ name: generation.model,
1028
+ inputTokens: callUsage?.inputTokens ?? 0,
1029
+ outputTokens: callUsage?.outputTokens ?? 0,
1030
+ cacheReadTokens: callUsage?.cacheReadTokens ?? 0,
1031
+ cacheWriteTokens: callUsage?.cacheWriteTokens ?? 0,
1032
+ costCentsX100: callCostCentsX100 ?? 0,
1033
+ durationMs: generation.latencyMs,
1034
+ status: generationStatus,
1035
+ errorMessage: generationError,
1036
+ metadata: null,
1037
+ createdAt: generation.createdAt,
1038
+ });
1039
+ emitLlmGenerationTrackingEvent({
1040
+ runId,
1041
+ threadId,
1042
+ userId,
1043
+ parentSpanId,
1044
+ llmSpanId: generation.spanId,
1045
+ engineName,
1046
+ model: generation.model,
1047
+ inputTokens: generation.tokensKnown
1048
+ ? callUsage?.inputTokens
1049
+ : undefined,
1050
+ outputTokens: generation.tokensKnown
1051
+ ? callUsage?.outputTokens
1052
+ : undefined,
1053
+ cacheReadTokens: generation.tokensKnown
1054
+ ? callUsage?.cacheReadTokens
1055
+ : undefined,
1056
+ cacheWriteTokens: generation.tokensKnown
1057
+ ? callUsage?.cacheWriteTokens
1058
+ : undefined,
1059
+ costCentsX100: callCostCentsX100,
1060
+ durationMs: generation.latencyMs,
1061
+ llmDurationMs: generation.latencyMs,
1062
+ llmDurationMeasured: measuredModelDurationMs !== undefined,
1063
+ stopReason: generation.stopReason,
1064
+ // One request per generation now. `$ai_request_count` is what
1065
+ // PostHog multiplies by per-request pricing, so it counts this
1066
+ // call, not the run.
1067
+ llmCallCount: modelRoundTrips.length > 0 ? 1 : llmCallCount,
1068
+ firstTokenMs: generation.isFirst ? runFirstTokenMs : undefined,
1069
+ status: generationStatus,
1070
+ errorMessage: generationError,
1071
+ toolCalls: generation.toolSpans.length,
1072
+ successfulTools: generation.toolSpans.filter((span) => span.status === "success").length,
1073
+ failedTools: generation.toolSpans.filter((span) => span.status === "error").length,
1074
+ tools: generation.toolDetails,
1075
+ toolsTruncated: toolInvocationCounter > MAX_TRACKED_GENERATION_TOOL_CALLS,
1076
+ // Only the layer that failed carries the run's terminal outcome;
1077
+ // on a healthy call `terminal_state: failed` reads as this call
1078
+ // having failed.
1079
+ terminalOutcome: generationStatus === "error"
1080
+ ? effectiveTerminalOutcome
1081
+ : undefined,
1082
+ delegation: opts.delegation,
1083
+ createdAt: generation.createdAt,
1084
+ experimentAssignments: opts.experimentAssignments,
1085
+ modelSelectionSource: opts.modelSelectionSource,
1086
+ browserSessionId,
1087
+ ...generationContent,
1088
+ });
1089
+ }
921
1090
  }
922
1091
  const parentSpan = {
923
1092
  id: parentSpanId,
@@ -959,22 +1128,6 @@ export async function instrumentAgentLoop(opts) {
959
1128
  const provider = llmProviderFromEngine(typeof loopOpts.engine?.name === "string"
960
1129
  ? loopOpts.engine.name
961
1130
  : undefined, usage?.model ?? loopOpts.model);
962
- // PostHog reads a trace's input/output state ONLY from the `$ai_trace`
963
- // event — never from its children — so a trace whose detail view should
964
- // show what the run was asked and what it answered has to carry them
965
- // here. Gated on `capturePrompts` and omitted, not emptied, when off.
966
- const traceInputState = config.capturePrompts
967
- ? redactSensitiveFields(loopOpts.messages)
968
- : undefined;
969
- const traceAssistantText = assistantTextParts.join("");
970
- const traceOutputState = config.capturePrompts && traceAssistantText
971
- ? [
972
- {
973
- role: "assistant",
974
- content: redactToolErrorMessage(traceAssistantText),
975
- },
976
- ]
977
- : undefined;
978
1131
  emitAiTraceEvent({
979
1132
  runId,
980
1133
  threadId,
@@ -985,6 +1138,16 @@ export async function instrumentAgentLoop(opts) {
985
1138
  durationMs: totalDurationMs,
986
1139
  isError: runStatus === "error",
987
1140
  error: aiError,
1141
+ // What ended the run: our own budget or timeout (`run_timeout`,
1142
+ // `no_progress`), a terminal outcome code, or a failure with neither.
1143
+ errorType: runStatus === "error"
1144
+ ? (cutOffReason ??
1145
+ (effectiveTerminalOutcome?.state === "failed" ||
1146
+ effectiveTerminalOutcome?.state === "input_required"
1147
+ ? effectiveTerminalOutcome.code
1148
+ : undefined) ??
1149
+ effectiveTerminalOutcome?.state)
1150
+ : undefined,
988
1151
  inputTokens: usage?.usageReported ? usage.inputTokens : undefined,
989
1152
  outputTokens: usage?.usageReported ? usage.outputTokens : undefined,
990
1153
  costUsd: usage?.usageReported
@@ -992,13 +1155,20 @@ export async function instrumentAgentLoop(opts) {
992
1155
  : undefined,
993
1156
  createdAt: runStart,
994
1157
  browserSessionId,
995
- inputState: traceInputState,
996
- outputState: traceOutputState,
997
1158
  extraProperties: {
998
1159
  ...trackingIdentityProperties(),
999
1160
  source: "agent_observability",
1000
1161
  run_id: runId,
1001
1162
  thread_id: threadId,
1163
+ // Run totals. Each generation counts only the call it describes, so
1164
+ // without these the run-level numbers would have to be summed back
1165
+ // out of the children.
1166
+ llm_calls: llmCallCount || undefined,
1167
+ tool_calls: toolCallCount,
1168
+ successful_tools: successfulTools,
1169
+ failed_tools: failedTools,
1170
+ time_to_first_token_ms: runFirstTokenMs,
1171
+ latency_source: measuredModelDurationMs !== undefined ? "measured" : "derived",
1002
1172
  ...aiTraceMetadataProperties(runMetadata),
1003
1173
  // Present for planned boundaries too, which are not errors: the ratio
1004
1174
  // of run_timeout to no_progress is the signal, and it is unreadable
@@ -1026,17 +1196,35 @@ export async function instrumentAgentLoop(opts) {
1026
1196
  config.captureToolResults
1027
1197
  ? truncateToolErrorMessage(redactToolErrorMessage(span.errorMessage))
1028
1198
  : undefined;
1199
+ // "Withheld" and "never reported" are different failures to debug,
1200
+ // and a span that says only `$ai_is_error` tells the reader neither.
1201
+ const toolErrorDetail = span.status !== "error"
1202
+ ? undefined
1203
+ : toolErrorMessage
1204
+ ? toAiErrorDetail(toolErrorMessage)
1205
+ : span.errorMessage
1206
+ ? {
1207
+ message: "error text withheld: captureToolResults is off for this app",
1208
+ }
1209
+ : undefined;
1210
+ const requestingGeneration = toolSpanRoundTrip.get(span.id);
1029
1211
  emitAiSpanEvent({
1030
1212
  runId,
1031
1213
  threadId,
1032
1214
  userId,
1033
1215
  spanId: span.id,
1216
+ // Under the generation that asked for it, so PostHog draws the run
1217
+ // as call → tools → call. Falls back to the trace root when the
1218
+ // engine never bracketed its model calls and there is no
1219
+ // generation to hang the tool under.
1220
+ parentId: requestingGeneration !== undefined
1221
+ ? modelRoundTrips[requestingGeneration]?.spanId
1222
+ : undefined,
1034
1223
  spanName: span.name,
1035
1224
  latencySeconds: Math.round(span.durationMs) / 1000,
1036
1225
  isError: span.status === "error",
1037
- error: toolErrorMessage
1038
- ? toAiErrorDetail(toolErrorMessage)
1039
- : undefined,
1226
+ error: toolErrorDetail,
1227
+ errorType: toolSpanErrorClass.get(span.id),
1040
1228
  createdAt: span.createdAt,
1041
1229
  browserSessionId,
1042
1230
  // `metadata.input` / `metadata.output` are already redacted and