blob: 2d69a68ebdf9dcc0f7f91cc99078ffe0c11e6994 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createHash } from 'node:crypto';
import { generalizedErrorMessage, redactSecrets } from '@maka/core/redaction';
import type {
CacheMissInputSource,
ContextBudgetDiagnostic,
ToolAvailabilityDiagnostic,
} from '@maka/core/usage-stats/types';
export type RunTracePhase =
| 'turn'
| 'model'
| 'tool'
| 'permission'
| 'sandbox'
| 'skill'
| 'plan'
| 'agent_graph'
| 'abort'
| 'usage';
export type RunTraceEventType =
| 'turn_started'
| 'plan_context_resolved'
| 'plan_submitted'
| 'plan_execution_started'
| 'plan_progress_updated'
| 'plan_execution_completed'
| 'plan_execution_cancelled'
| 'plan_execution_interrupted'
| 'plan_execution_resumed'
| 'plan_transition_failed'
| 'graph_supervisor_yielded'
| 'model_resolved'
| 'model_resolve_failed'
| 'model_stream_started'
| 'model_stream_completed'
| 'model_stream_failed'
| 'send_diagnostics_recorded'
| 'tool_started'
| 'tool_searched'
| 'tool_completed'
| 'tool_failed'
| 'skill_catalog_built'
| 'skill_searched'
| 'skill_loaded'
| 'skill_load_failed'
| 'permission_requested'
| 'permission_decided'
| 'permission_failed'
| 'approval_routed'
| 'auto_review_started'
| 'auto_review_decided'
| 'auto_review_failed'
| 'sandbox_escalation_requested'
| 'sandbox_escalation_granted'
| 'sandbox_escalation_denied'
| 'sandbox_escalation_applied'
| 'sandbox_escalation_failed'
| 'sandbox_denial_detected'
| 'abort_requested';
export interface RunTraceEvent {
id: string;
sessionId: string;
turnId: string;
ts: number;
phase: RunTracePhase;
type: RunTraceEventType;
message: string;
data?: Record<string, unknown>;
}
export type RunTraceRecorder = (event: RunTraceEvent) => unknown;
const REDACTED_ERROR_MESSAGE_MAX_CHARS = 2_048;
export interface RunTraceInput {
sessionId: string;
turnId: string;
connectionSlug: string;
providerId: string;
modelId: string;
newId: () => string;
now: () => number;
record?: RunTraceRecorder;
}
export class RunTrace {
constructor(private readonly input: RunTraceInput) {}
emit(
phase: RunTracePhase,
type: RunTraceEventType,
message: string,
data?: Record<string, unknown>,
): void {
const event: RunTraceEvent = {
id: this.input.newId(),
sessionId: this.input.sessionId,
turnId: this.input.turnId,
ts: this.input.now(),
phase,
type,
message,
...(data ? { data: sanitizeTraceData(data) } : {}),
};
try {
const recorded = this.input.record?.(event);
if (isPromiseLike(recorded)) void Promise.resolve(recorded).catch(() => {});
} catch {
// Tracing is diagnostic-only and must not perturb model/tool execution.
}
}
turnStarted(extra: Record<string, unknown> = {}): void {
this.emit('turn', 'turn_started', 'Turn started', {
connectionSlug: this.input.connectionSlug,
providerId: this.input.providerId,
modelId: this.input.modelId,
...extra,
});
}
modelResolved(): void {
this.emit('model', 'model_resolved', 'Model resolved', {
connectionSlug: this.input.connectionSlug,
providerId: this.input.providerId,
modelId: this.input.modelId,
});
}
modelResolveFailed(error: unknown): void {
this.emit('model', 'model_resolve_failed', 'Model resolution failed', {
error: explainError(error),
});
}
modelStreamStarted(
activeTools: readonly string[],
diagnostics?: {
toolAvailability?: ToolAvailabilityDiagnostic;
contextBudget?: ContextBudgetDiagnostic;
},
): void {
this.emit('model', 'model_stream_started', 'Model stream started', {
activeTools: [...activeTools],
...(diagnostics !== undefined ? diagnostics : {}),
});
}
modelStreamCompleted(stopReason: string): void {
this.emit('model', 'model_stream_completed', 'Model stream completed', {
stopReason,
});
}
modelStreamFailed(
errorClass: string | undefined,
error: unknown,
replay?: { gate: string; diagnosticCodes: readonly string[] },
): void {
this.emit('model', 'model_stream_failed', 'Model stream failed', {
...(errorClass ? { errorClass } : {}),
...(replay
? {
priorReplayGate: replay.gate,
priorReplayDiagnosticCodes: [...replay.diagnosticCodes],
}
: {}),
error: explainError(error),
...diagnoseError(error),
});
}
/**
* Terminal diagnostics for one send: what the request actually cost in
* context, and how the send ended.
*
* Explicitly not accounting. It carries no cost and is not summed by
* anything — spend lives in `ModelCallAttempt`, one record per physical
* provider request (#1679). This exists because the exhausted and aborted
* paths produce no `token_usage` SessionEvent, so their compaction decisions
* and accumulated step usage would otherwise have no durable home at all.
*/
sendDiagnostics(diagnostics: {
status: string;
errorClass?: string;
inputTokens?: number;
outputTokens?: number;
totalTokens?: number;
contextBudget?: unknown;
toolAvailability?: ToolAvailabilityDiagnostic;
}): void {
this.emit('model', 'send_diagnostics_recorded', 'Send diagnostics recorded', {
status: diagnostics.status,
...(diagnostics.errorClass !== undefined ? { errorClass: diagnostics.errorClass } : {}),
...(diagnostics.inputTokens !== undefined ? { inputTokens: diagnostics.inputTokens } : {}),
...(diagnostics.outputTokens !== undefined ? { outputTokens: diagnostics.outputTokens } : {}),
...(diagnostics.totalTokens !== undefined ? { totalTokens: diagnostics.totalTokens } : {}),
...(diagnostics.contextBudget !== undefined
? { contextBudget: diagnostics.contextBudget }
: {}),
...(diagnostics.toolAvailability !== undefined
? { toolAvailability: diagnostics.toolAvailability }
: {}),
});
}
abortRequested(reason: string): void {
this.emit('abort', 'abort_requested', 'Abort requested', { reason });
}
}
export interface RunTraceLike {
emit(
phase: RunTracePhase,
type: RunTraceEventType,
message: string,
data?: Record<string, unknown>,
): void;
}
export function explainError(error: unknown): string {
return generalizedErrorMessage(error);
}
function diagnoseError(error: unknown): Record<string, unknown> {
const rawMessage = rawErrorMessage(error);
const redactedMessage = redactSecrets(rawMessage);
const stack =
error instanceof Error && typeof error.stack === 'string'
? redactSecrets(error.stack)
: undefined;
const message = truncate(redactedMessage, REDACTED_ERROR_MESSAGE_MAX_CHARS);
return {
rawErrorName: rawErrorName(error),
rawErrorType: typeof error,
redactedErrorMessage: message.text,
redactedErrorMessageSha256: sha256(redactedMessage),
...(message.truncated ? { redactedErrorMessageTruncated: true } : {}),
...(stack ? { redactedErrorStackSha256: sha256(stack) } : {}),
};
}
function rawErrorName(error: unknown): string {
if (error instanceof Error && typeof error.name === 'string' && error.name.length > 0) {
return error.name;
}
if (error === null) return 'null';
return typeof error;
}
function rawErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
try {
const serialized = JSON.stringify(error);
if (typeof serialized === 'string') return serialized;
} catch {
// Fall back to string coercion for cyclic or otherwise unserializable values.
}
try {
return String(error);
} catch {
return '[unprintable error]';
}
}
function truncate(value: string, maxChars: number): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false };
return { text: value.slice(0, maxChars), truncated: true };
}
function sha256(value: string): string {
return `sha256:${createHash('sha256').update(value).digest('hex')}`;
}
function sanitizeTraceData(data: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined));
}
function isPromiseLike(value: unknown): value is PromiseLike<unknown> {
return Boolean(
value &&
(typeof value === 'object' || typeof value === 'function') &&
'then' in value &&
typeof value.then === 'function',
);
}