@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/calendar/actions/get-settings.ts +2 -5
- package/corpus/templates/calendar/actions/list-events.ts +21 -80
- package/corpus/templates/calendar/actions/update-settings.ts +11 -16
- package/corpus/templates/calendar/actions/view-screen.ts +25 -10
- package/corpus/templates/calendar/app/components/calendar/CommandPalette.tsx +7 -1
- package/corpus/templates/calendar/app/components/calendar/EventCard.tsx +5 -2
- package/corpus/templates/calendar/app/lib/calendar-timezone.ts +3 -18
- package/corpus/templates/calendar/app/lib/event-form-utils.ts +3 -62
- package/corpus/templates/calendar/changelog/2026-07-22-calendar-grid-local-time.md +6 -0
- package/corpus/templates/calendar/changelog/2026-08-19-the-calendar-loads-again-for-accounts-whose-saved-timezone-w.md +6 -0
- package/corpus/templates/calendar/server/handlers/settings.ts +8 -24
- package/corpus/templates/calendar/server/lib/calendar-settings.ts +58 -0
- package/corpus/templates/calendar/server/lib/find-time.ts +15 -93
- package/corpus/templates/calendar/shared/settings.ts +15 -9
- package/corpus/templates/calendar/shared/timezone.ts +127 -0
- package/corpus/templates/slides/actions/list-decks.ts +79 -5
- package/corpus/templates/slides/app/components/deck/DeckCard.tsx +21 -1
- package/corpus/templates/slides/app/components/editor/ShareDialog.tsx +49 -21
- package/corpus/templates/slides/app/context/DeckContext.tsx +49 -21
- package/corpus/templates/slides/app/pages/DeckEditor.tsx +2 -2
- package/corpus/templates/slides/app/pages/Index.tsx +11 -13
- package/corpus/templates/slides/changelog/2026-08-24-deck-previews-and-sharing-live-in-the-overflow-menu.md +5 -0
- package/dist/agent/production-agent.js +5 -1
- package/dist/agent/types.d.ts +5 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/observability/posthog-ai.d.ts +36 -11
- package/dist/observability/posthog-ai.js +99 -30
- package/dist/observability/routes.d.ts +3 -3
- package/dist/observability/routes.js +9 -2
- package/dist/observability/traces.js +330 -142
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/server/realtime-token.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/tracking/providers.js +9 -3
- package/docs/content/observability.mdx +27 -10
- 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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
242
|
+
* Build the PostHog content fields for one `$ai_generation`.
|
|
241
243
|
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
246
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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). `
|
|
833
|
-
//
|
|
834
|
-
//
|
|
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
|
|
837
|
-
?
|
|
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
|
|
932
|
+
const derivedLlmDurationMs = measuredModelDurationMs ??
|
|
849
933
|
Math.max(0, totalDurationMs -
|
|
850
934
|
coveredDurationMs(spanIntervals(emittedToolSpans)));
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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:
|
|
1038
|
-
|
|
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
|