@senad-d/observme 0.1.0
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/.env.example +57 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/ObservMe-Production-Docs/00-README.md +79 -0
- package/ObservMe-Production-Docs/01-requirements-and-scope.md +207 -0
- package/ObservMe-Production-Docs/02-reference-architecture.md +306 -0
- package/ObservMe-Production-Docs/03-pi-event-and-session-model.md +266 -0
- package/ObservMe-Production-Docs/04-telemetry-semantic-conventions.md +722 -0
- package/ObservMe-Production-Docs/05-otel-pipeline-and-collector.md +355 -0
- package/ObservMe-Production-Docs/06-security-privacy-redaction.md +294 -0
- package/ObservMe-Production-Docs/07-extension-implementation-blueprint.md +447 -0
- package/ObservMe-Production-Docs/08-query-grafana-integration.md +276 -0
- package/ObservMe-Production-Docs/09-dashboards-alerts-slos.md +640 -0
- package/ObservMe-Production-Docs/10-testing-release-operations.md +319 -0
- package/ObservMe-Production-Docs/11-deployment-runbooks.md +203 -0
- package/ObservMe-Production-Docs/12-configuration-reference.md +337 -0
- package/ObservMe-Production-Docs/13-source-notes.md +33 -0
- package/ObservMe-Production-Docs/pi-session-format.md +427 -0
- package/README.md +356 -0
- package/SECURITY.md +45 -0
- package/dashboards/observme-agent-node-graphs.json +250 -0
- package/dashboards/observme-agents.json +1880 -0
- package/dashboards/observme-alerts.yaml +113 -0
- package/dashboards/observme-branches-compactions.json +1042 -0
- package/dashboards/observme-cost.json +1254 -0
- package/dashboards/observme-errors.json +1659 -0
- package/dashboards/observme-export-health.json +1802 -0
- package/dashboards/observme-latency.json +1494 -0
- package/dashboards/observme-llm-conversations.json +730 -0
- package/dashboards/observme-logs-llm.json +644 -0
- package/dashboards/observme-models.json +933 -0
- package/dashboards/observme-overview.json +2129 -0
- package/dashboards/observme-slo-health.json +737 -0
- package/dashboards/observme-slos.yaml +56 -0
- package/dashboards/observme-tools.json +902 -0
- package/dashboards/observme-trace-journey.json +1793 -0
- package/docs/STRUCTURE.md +49 -0
- package/docs/agent-subagent-observability-requirements.md +997 -0
- package/docs/compatibility-matrix.md +39 -0
- package/docs/configuration-tui-design-standard.md +767 -0
- package/docs/configuration.md +38 -0
- package/docs/review-validation.md +115 -0
- package/docs/validation-flow.md +117 -0
- package/examples/collector.yaml +123 -0
- package/examples/observme.yaml +131 -0
- package/img/demo.gif +0 -0
- package/img/icon.svg +47 -0
- package/package.json +103 -0
- package/src/commands/obs-agents-runtime.ts +150 -0
- package/src/commands/obs-agents.ts +491 -0
- package/src/commands/obs-args.ts +63 -0
- package/src/commands/obs-backfill.ts +1334 -0
- package/src/commands/obs-command-support.ts +43 -0
- package/src/commands/obs-cost.ts +228 -0
- package/src/commands/obs-diagnostics.ts +22 -0
- package/src/commands/obs-errors.ts +156 -0
- package/src/commands/obs-health.ts +301 -0
- package/src/commands/obs-link.ts +90 -0
- package/src/commands/obs-logs.ts +194 -0
- package/src/commands/obs-loki-summary.ts +184 -0
- package/src/commands/obs-session.ts +259 -0
- package/src/commands/obs-status.ts +359 -0
- package/src/commands/obs-tools.ts +274 -0
- package/src/commands/obs-trace.ts +411 -0
- package/src/commands/obs.ts +211 -0
- package/src/config/bootstrap-project-config.ts +300 -0
- package/src/config/defaults.ts +143 -0
- package/src/config/load-config.ts +631 -0
- package/src/config/project-paths.ts +61 -0
- package/src/config/schema.ts +405 -0
- package/src/config/validate.ts +456 -0
- package/src/constants.ts +4 -0
- package/src/diagnostics/sanitize.ts +6 -0
- package/src/extension.ts +38 -0
- package/src/otel/logs.ts +160 -0
- package/src/otel/metrics.ts +165 -0
- package/src/otel/otlp-endpoint.ts +10 -0
- package/src/otel/sdk.ts +114 -0
- package/src/otel/shutdown.ts +102 -0
- package/src/otel/traces.ts +166 -0
- package/src/pi/agent-lineage.ts +378 -0
- package/src/pi/agent-tree-tracker.ts +258 -0
- package/src/pi/event-handlers/agent-turn.ts +155 -0
- package/src/pi/event-handlers/lifecycle.ts +642 -0
- package/src/pi/event-handlers/llm.ts +115 -0
- package/src/pi/event-handlers/session-events.ts +159 -0
- package/src/pi/event-handlers/tool-bash.ts +275 -0
- package/src/pi/handler-internals.ts +2154 -0
- package/src/pi/handler-runtime.ts +633 -0
- package/src/pi/handler-types.ts +261 -0
- package/src/pi/handlers.ts +75 -0
- package/src/pi/subagent-spawn.ts +975 -0
- package/src/pi/subagent-types.ts +29 -0
- package/src/privacy/content-capture.ts +104 -0
- package/src/privacy/hash.ts +93 -0
- package/src/privacy/redact.ts +619 -0
- package/src/privacy/secret-patterns.ts +185 -0
- package/src/privacy/truncate.ts +69 -0
- package/src/query/grafana-readiness.ts +164 -0
- package/src/query/grafana-transport.ts +481 -0
- package/src/query/grafana.ts +371 -0
- package/src/query/loki.ts +332 -0
- package/src/query/prometheus.ts +388 -0
- package/src/query/tempo.ts +332 -0
- package/src/safety/sensitive-input.ts +208 -0
- package/src/semconv/attributes.ts +279 -0
- package/src/semconv/metrics.ts +146 -0
- package/src/semconv/spans.ts +19 -0
- package/src/semconv/values.ts +13 -0
- package/src/util/bounded-map.ts +97 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,1334 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import type { ExtensionAPI, SessionEntry, SessionHeader } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import type { LoadSessionConfigOptions } from "../config/load-config.ts";
|
|
4
|
+
import type { ObservMeConfig } from "../config/schema.ts";
|
|
5
|
+
import { ObservMeLogSdk } from "../otel/logs.ts";
|
|
6
|
+
import type { BoundedOtelOperationResult } from "../otel/shutdown.ts";
|
|
7
|
+
import { flushOtelSdk, shutdownOtelSdk } from "../otel/shutdown.ts";
|
|
8
|
+
import { applyContentCapturePolicy } from "../privacy/content-capture.ts";
|
|
9
|
+
import type { ContentLimitKind } from "../privacy/truncate.ts";
|
|
10
|
+
import {
|
|
11
|
+
BASH_ATTRIBUTES,
|
|
12
|
+
BRANCH_ATTRIBUTES,
|
|
13
|
+
COMMON_SPAN_ATTRIBUTES,
|
|
14
|
+
COMPACTION_ATTRIBUTES,
|
|
15
|
+
LLM_ATTRIBUTES,
|
|
16
|
+
LOG_ATTRIBUTES,
|
|
17
|
+
MESSAGE_ATTRIBUTES,
|
|
18
|
+
TOOL_ATTRIBUTES,
|
|
19
|
+
} from "../semconv/attributes.ts";
|
|
20
|
+
import { LOG_EVENT_NAMES } from "../semconv/metrics.ts";
|
|
21
|
+
import {
|
|
22
|
+
completeObsSubcommand,
|
|
23
|
+
missingObsOptionValueMessage,
|
|
24
|
+
parseObsSubcommandArgs,
|
|
25
|
+
unknownObsOptionMessage,
|
|
26
|
+
} from "./obs-args.ts";
|
|
27
|
+
import { loadObsCommandConfig, normalizeObsCommandTimeoutMs, notifyObsCommand } from "./obs-command-support.ts";
|
|
28
|
+
|
|
29
|
+
export type ObsBackfillAttributeValue = boolean | number | string | string[];
|
|
30
|
+
export type ObsBackfillAttributes = Record<string, ObsBackfillAttributeValue>;
|
|
31
|
+
|
|
32
|
+
export interface ObsBackfillCommandContext {
|
|
33
|
+
readonly cwd?: string;
|
|
34
|
+
readonly hasUI?: boolean;
|
|
35
|
+
readonly signal?: AbortSignal;
|
|
36
|
+
readonly ui: {
|
|
37
|
+
notify: (message: string, type?: "info" | "warning" | "error") => Promise<void> | void;
|
|
38
|
+
confirm?: (title: string, message: string, options?: { timeout?: number; signal?: AbortSignal }) => Promise<boolean> | boolean;
|
|
39
|
+
};
|
|
40
|
+
readonly isProjectTrusted?: () => boolean | Promise<boolean>;
|
|
41
|
+
readonly waitForIdle?: (options?: ObsBackfillOperationOptions) => Promise<void> | void;
|
|
42
|
+
readonly sessionManager?: ObsBackfillSessionManager;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface ObsBackfillOperationOptions {
|
|
46
|
+
/**
|
|
47
|
+
* Cooperative cancellation signal for this backfill run. Custom exporters should stop
|
|
48
|
+
* pending work when this signal is aborted; non-cancellable work must be idempotent
|
|
49
|
+
* and avoid emitting additional records after observing cancellation.
|
|
50
|
+
*/
|
|
51
|
+
readonly signal?: AbortSignal;
|
|
52
|
+
readonly timeoutMs?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ObsBackfillSessionManager {
|
|
56
|
+
readonly getBranch?: () => readonly SessionEntry[];
|
|
57
|
+
readonly getEntries?: () => readonly SessionEntry[];
|
|
58
|
+
readonly getHeader?: () => SessionHeader | null;
|
|
59
|
+
readonly getSessionId?: () => string;
|
|
60
|
+
readonly getSessionFile?: () => string | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ObsBackfillTelemetryRecord {
|
|
64
|
+
readonly eventName: string;
|
|
65
|
+
readonly body: string;
|
|
66
|
+
readonly attributes: ObsBackfillAttributes;
|
|
67
|
+
readonly timestamp?: Date;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ObsBackfillExporter {
|
|
71
|
+
emit: (record: ObsBackfillTelemetryRecord, options?: ObsBackfillOperationOptions) => Promise<void> | void;
|
|
72
|
+
flush?: (options?: ObsBackfillOperationOptions) => Promise<void> | void;
|
|
73
|
+
shutdown?: (options?: ObsBackfillOperationOptions) => Promise<void> | void;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ObsBackfillSummary {
|
|
77
|
+
readonly status: "completed" | "cancelled" | "skipped";
|
|
78
|
+
readonly sessionId?: string;
|
|
79
|
+
readonly sessionFile?: string;
|
|
80
|
+
readonly since?: string;
|
|
81
|
+
readonly entriesScanned: number;
|
|
82
|
+
readonly entriesEligible: number;
|
|
83
|
+
readonly recordsExported: number;
|
|
84
|
+
readonly recordsSkipped: number;
|
|
85
|
+
readonly rateLimited: boolean;
|
|
86
|
+
readonly contentCaptured: boolean;
|
|
87
|
+
readonly redactionFailures: number;
|
|
88
|
+
readonly reason?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type ObsBackfillConfigLoader = (options: LoadSessionConfigOptions) => Promise<ObservMeConfig>;
|
|
92
|
+
export type ObsBackfillExporterFactory = (
|
|
93
|
+
config: ObservMeConfig,
|
|
94
|
+
ctx: ObsBackfillCommandContext,
|
|
95
|
+
options?: ObsBackfillOperationOptions,
|
|
96
|
+
) => ObsBackfillExporter | Promise<ObsBackfillExporter>;
|
|
97
|
+
export type ObsBackfillRunner = (
|
|
98
|
+
ctx: ObsBackfillCommandContext,
|
|
99
|
+
request: ObsBackfillRequest,
|
|
100
|
+
) => Promise<ObsBackfillSummary> | ObsBackfillSummary;
|
|
101
|
+
|
|
102
|
+
export interface ObsBackfillOptions {
|
|
103
|
+
readonly loadConfig?: ObsBackfillConfigLoader;
|
|
104
|
+
readonly createExporter?: ObsBackfillExporterFactory;
|
|
105
|
+
readonly runBackfill?: ObsBackfillRunner;
|
|
106
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
107
|
+
readonly configDirName?: string;
|
|
108
|
+
readonly now?: () => Date;
|
|
109
|
+
readonly maxRecords?: number;
|
|
110
|
+
readonly confirmTimeoutMs?: number;
|
|
111
|
+
readonly exportOperationTimeoutMs?: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type RegisterObsBackfillCommandOptions = ObsBackfillOptions;
|
|
115
|
+
|
|
116
|
+
export interface ObsBackfillRequest {
|
|
117
|
+
readonly currentSession: boolean;
|
|
118
|
+
readonly since?: string;
|
|
119
|
+
readonly sinceMs?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
interface ParsedObsBackfillArgs {
|
|
123
|
+
readonly request?: ObsBackfillRequest;
|
|
124
|
+
readonly error?: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface ObsBackfillBuildResult {
|
|
128
|
+
readonly records: readonly ObsBackfillTelemetryRecord[];
|
|
129
|
+
readonly entriesScanned: number;
|
|
130
|
+
readonly entriesEligible: number;
|
|
131
|
+
readonly recordsSkipped: number;
|
|
132
|
+
readonly rateLimited: boolean;
|
|
133
|
+
readonly contentCaptured: boolean;
|
|
134
|
+
readonly redactionFailures: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
interface BackfillSummaryInput {
|
|
138
|
+
readonly status: ObsBackfillSummary["status"];
|
|
139
|
+
readonly request: ObsBackfillRequest;
|
|
140
|
+
readonly buildResult: ObsBackfillBuildResult;
|
|
141
|
+
readonly recordsExported: number;
|
|
142
|
+
readonly reason?: string;
|
|
143
|
+
readonly sessionId?: string;
|
|
144
|
+
readonly sessionFile?: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
interface ObsBackfillFlagState {
|
|
148
|
+
currentSession: boolean;
|
|
149
|
+
since?: string;
|
|
150
|
+
index: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
interface ObsBackfillContentResult {
|
|
154
|
+
readonly captured: boolean;
|
|
155
|
+
readonly redactionFailures: number;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
interface ToolCallContent {
|
|
159
|
+
readonly name?: string;
|
|
160
|
+
readonly arguments?: unknown;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const OBS_COMMAND_NAME = "obs";
|
|
164
|
+
const OBS_BACKFILL_SUBCOMMAND = "backfill";
|
|
165
|
+
const OBS_BACKFILL_USAGE = "Usage: /obs backfill --current-session --since 1h";
|
|
166
|
+
const OBS_BACKFILL_DEFAULT_MAX_RECORDS = 100;
|
|
167
|
+
const OBS_BACKFILL_DEFAULT_OPERATION_TIMEOUT_MS = 30_000;
|
|
168
|
+
const OBS_BACKFILL_ABORT_CLEANUP_TIMEOUT_MS = 100;
|
|
169
|
+
const OBS_BACKFILL_CATEGORY = "backfill";
|
|
170
|
+
const OBS_BACKFILL_UNKNOWN_SESSION = "unknown";
|
|
171
|
+
const millisecondsPerSecond = 1000;
|
|
172
|
+
const millisecondsPerMinute = 60 * millisecondsPerSecond;
|
|
173
|
+
const millisecondsPerHour = 60 * millisecondsPerMinute;
|
|
174
|
+
const millisecondsPerDay = 24 * millisecondsPerHour;
|
|
175
|
+
const OBS_BACKFILL_MAX_SINCE_DAYS = 30;
|
|
176
|
+
const OBS_BACKFILL_MAX_SINCE_MS = OBS_BACKFILL_MAX_SINCE_DAYS * millisecondsPerDay;
|
|
177
|
+
const OBS_BACKFILL_SINCE_HELP = `Use a positive duration up to ${OBS_BACKFILL_MAX_SINCE_DAYS}d, such as 30m, 1h, or 2d.`;
|
|
178
|
+
const sincePattern = /^(\d+)(ms|s|m|h|d)$/iu;
|
|
179
|
+
const abortErrorNames = new Set(["AbortError", "TimeoutError"]);
|
|
180
|
+
|
|
181
|
+
export function registerObsBackfillCommand(
|
|
182
|
+
pi: ExtensionAPI,
|
|
183
|
+
options: RegisterObsBackfillCommandOptions = {},
|
|
184
|
+
): void {
|
|
185
|
+
const command = new ObsBackfillCommand(options);
|
|
186
|
+
|
|
187
|
+
pi.registerCommand(OBS_COMMAND_NAME, {
|
|
188
|
+
description: "Replay current-session ObservMe telemetry on demand. Usage: /obs backfill --current-session --since 1h",
|
|
189
|
+
getArgumentCompletions: getObsBackfillCommandArgumentCompletions,
|
|
190
|
+
handler: command.handle.bind(command),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function handleObsBackfillCommand(
|
|
195
|
+
args: string,
|
|
196
|
+
ctx: ObsBackfillCommandContext,
|
|
197
|
+
options: RegisterObsBackfillCommandOptions = {},
|
|
198
|
+
): Promise<void> {
|
|
199
|
+
const parsed = parseObsBackfillArgs(args);
|
|
200
|
+
if (!parsed.request) {
|
|
201
|
+
await notifyObsCommand(ctx, parsed.error ? `${OBS_BACKFILL_USAGE}\n${parsed.error}` : OBS_BACKFILL_USAGE, "warning");
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const summary = await resolveObsBackfillSummary(ctx, parsed.request, options);
|
|
207
|
+
await notifyObsCommand(ctx, renderObsBackfillSummary(summary), notificationTypeForSummary(summary));
|
|
208
|
+
} catch (error) {
|
|
209
|
+
await notifyObsCommand(ctx, `ObservMe backfill unavailable: ${formatBackfillError(error)}`, "error");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function getObsBackfillCommandArgumentCompletions(
|
|
214
|
+
prefix: string,
|
|
215
|
+
): Array<{ value: string; label: string }> | null {
|
|
216
|
+
return completeObsSubcommand(prefix, OBS_BACKFILL_SUBCOMMAND);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function runObsBackfill(
|
|
220
|
+
ctx: ObsBackfillCommandContext,
|
|
221
|
+
request: ObsBackfillRequest,
|
|
222
|
+
options: ObsBackfillOptions = {},
|
|
223
|
+
): Promise<ObsBackfillSummary> {
|
|
224
|
+
if (isAbortSignalAborted(ctx.signal)) return cancelledBackfillSummary(ctx, request, "operation cancelled");
|
|
225
|
+
|
|
226
|
+
const config = await loadObsBackfillConfig(ctx, options);
|
|
227
|
+
const skippedSummary = resolveSkippedBackfillSummary(config, ctx, request);
|
|
228
|
+
if (skippedSummary) return skippedSummary;
|
|
229
|
+
|
|
230
|
+
const confirmed = await confirmBackfillOrCancel(ctx, request, config, options);
|
|
231
|
+
if (!confirmed) return cancelledBackfillSummary(ctx, request, isAbortSignalAborted(ctx.signal) ? "operation cancelled" : undefined);
|
|
232
|
+
if (isAbortSignalAborted(ctx.signal)) return cancelledBackfillSummary(ctx, request, "operation cancelled");
|
|
233
|
+
|
|
234
|
+
const operationTimeoutMs = normalizeBackfillOperationTimeoutMs(options.exportOperationTimeoutMs);
|
|
235
|
+
const runScope = new ObsBackfillRunScope(ctx.signal);
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
return await runObsBackfillWithScope(ctx, request, config, operationTimeoutMs, options, runScope);
|
|
239
|
+
} finally {
|
|
240
|
+
runScope.cleanup();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function renderObsBackfillSummary(summary: ObsBackfillSummary): string {
|
|
245
|
+
if (summary.status === "cancelled") return renderCancelledObsBackfillSummary(summary);
|
|
246
|
+
if (summary.status === "skipped") return `ObservMe backfill skipped: ${summary.reason ?? "not available"}.`;
|
|
247
|
+
|
|
248
|
+
const lines = [
|
|
249
|
+
`Backfilled session: ${summary.sessionId ?? OBS_BACKFILL_UNKNOWN_SESSION}`,
|
|
250
|
+
`Window: ${summary.since ?? "all current-session entries"}`,
|
|
251
|
+
`Entries scanned: ${summary.entriesScanned}`,
|
|
252
|
+
`Entries eligible: ${summary.entriesEligible}`,
|
|
253
|
+
`Records exported: ${summary.recordsExported}`,
|
|
254
|
+
`Content captured: ${formatBoolean(summary.contentCaptured)}`,
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
if (summary.rateLimited) lines.push(`Rate limit: applied; skipped ${summary.recordsSkipped} eligible record(s)`);
|
|
258
|
+
if (summary.redactionFailures > 0) lines.push(`Redaction failures: ${summary.redactionFailures}`);
|
|
259
|
+
return lines.join("\n");
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function buildObsBackfillRecords(
|
|
263
|
+
entries: readonly SessionEntry[],
|
|
264
|
+
config: ObservMeConfig,
|
|
265
|
+
request: ObsBackfillRequest,
|
|
266
|
+
sessionId: string | undefined,
|
|
267
|
+
options: Pick<ObsBackfillOptions, "maxRecords" | "now"> = {},
|
|
268
|
+
): ObsBackfillBuildResult {
|
|
269
|
+
const cutoffMs = resolveSinceCutoffMs(request, options);
|
|
270
|
+
const maxRecords = normalizeMaxRecords(options.maxRecords);
|
|
271
|
+
const records: ObsBackfillTelemetryRecord[] = [];
|
|
272
|
+
let entriesEligible = 0;
|
|
273
|
+
let recordsSkipped = 0;
|
|
274
|
+
let rateLimited = false;
|
|
275
|
+
let contentCaptured = false;
|
|
276
|
+
let redactionFailures = 0;
|
|
277
|
+
|
|
278
|
+
for (const entry of entries) {
|
|
279
|
+
if (!entryIsWithinSince(entry, cutoffMs)) continue;
|
|
280
|
+
entriesEligible += 1;
|
|
281
|
+
|
|
282
|
+
if (records.length >= maxRecords) {
|
|
283
|
+
recordsSkipped += 1;
|
|
284
|
+
rateLimited = true;
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const conversion = sessionEntryToBackfillRecord(entry, config, sessionId);
|
|
289
|
+
if (!conversion.record) continue;
|
|
290
|
+
|
|
291
|
+
records.push(conversion.record);
|
|
292
|
+
contentCaptured = contentCaptured || conversion.contentCaptured;
|
|
293
|
+
redactionFailures += conversion.redactionFailures;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
return {
|
|
297
|
+
records,
|
|
298
|
+
entriesScanned: entries.length,
|
|
299
|
+
entriesEligible,
|
|
300
|
+
recordsSkipped,
|
|
301
|
+
rateLimited,
|
|
302
|
+
contentCaptured,
|
|
303
|
+
redactionFailures,
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export function createObsBackfillLogExporter(
|
|
308
|
+
config: ObservMeConfig,
|
|
309
|
+
options: ObsBackfillOperationOptions = {},
|
|
310
|
+
): ObsBackfillExporter {
|
|
311
|
+
const sdk = new ObservMeLogSdk({ config });
|
|
312
|
+
sdk.start();
|
|
313
|
+
return new ObsBackfillLogExporter(sdk, resolveBackfillOperationTimeoutMs(options, OBS_BACKFILL_DEFAULT_OPERATION_TIMEOUT_MS));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
class ObsBackfillInterruptedError extends Error {
|
|
317
|
+
readonly reason: string;
|
|
318
|
+
readonly recordsExported: number;
|
|
319
|
+
readonly timedOut: boolean;
|
|
320
|
+
|
|
321
|
+
constructor(reason: string, recordsExported = 0, timedOut = false) {
|
|
322
|
+
super(reason);
|
|
323
|
+
this.name = "ObsBackfillInterruptedError";
|
|
324
|
+
this.reason = reason;
|
|
325
|
+
this.recordsExported = recordsExported;
|
|
326
|
+
this.timedOut = timedOut;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
class ObsBackfillRunScope {
|
|
331
|
+
readonly #controller = new AbortController();
|
|
332
|
+
readonly #parentSignal?: AbortSignal;
|
|
333
|
+
readonly #abortFromParent = (): void => {
|
|
334
|
+
this.abort("operation cancelled");
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
constructor(parentSignal: AbortSignal | undefined) {
|
|
338
|
+
this.#parentSignal = parentSignal;
|
|
339
|
+
if (parentSignal?.aborted) {
|
|
340
|
+
this.abort("operation cancelled");
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
parentSignal?.addEventListener("abort", this.#abortFromParent, { once: true });
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
get signal(): AbortSignal {
|
|
348
|
+
return this.#controller.signal;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
abort(reason: string): void {
|
|
352
|
+
if (!this.#controller.signal.aborted) this.#controller.abort(reason);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
cleanup(): void {
|
|
356
|
+
this.#parentSignal?.removeEventListener("abort", this.#abortFromParent);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
class ObsBackfillCommand {
|
|
361
|
+
readonly #options: RegisterObsBackfillCommandOptions;
|
|
362
|
+
|
|
363
|
+
constructor(options: RegisterObsBackfillCommandOptions) {
|
|
364
|
+
this.#options = options;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async handle(args: string, ctx: ObsBackfillCommandContext): Promise<void> {
|
|
368
|
+
await handleObsBackfillCommand(args, ctx, this.#options);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
class ObsBackfillLogExporter implements ObsBackfillExporter {
|
|
373
|
+
readonly #sdk: ObservMeLogSdk;
|
|
374
|
+
readonly #operationTimeoutMs: number;
|
|
375
|
+
|
|
376
|
+
constructor(sdk: ObservMeLogSdk, operationTimeoutMs: number) {
|
|
377
|
+
this.#sdk = sdk;
|
|
378
|
+
this.#operationTimeoutMs = operationTimeoutMs;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
emit(record: ObsBackfillTelemetryRecord, options?: ObsBackfillOperationOptions): void {
|
|
382
|
+
throwIfBackfillAborted(options?.signal);
|
|
383
|
+
this.#sdk.logger.emit({
|
|
384
|
+
severityText: "INFO",
|
|
385
|
+
body: record.body,
|
|
386
|
+
attributes: {
|
|
387
|
+
[LOG_ATTRIBUTES.EVENT_NAME]: record.eventName,
|
|
388
|
+
[LOG_ATTRIBUTES.EVENT_CATEGORY]: OBS_BACKFILL_CATEGORY,
|
|
389
|
+
...record.attributes,
|
|
390
|
+
},
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
async flush(options?: ObsBackfillOperationOptions): Promise<void> {
|
|
395
|
+
throwIfBackfillAborted(options?.signal);
|
|
396
|
+
const result = await flushOtelSdk(this.#sdk, resolveBackfillOperationTimeoutMs(options, this.#operationTimeoutMs));
|
|
397
|
+
throwIfBackfillOtelOperationFailed(result, "export flush");
|
|
398
|
+
throwIfBackfillAborted(options?.signal);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async shutdown(options?: ObsBackfillOperationOptions): Promise<void> {
|
|
402
|
+
const result = await shutdownOtelSdk(this.#sdk, resolveBackfillOperationTimeoutMs(options, this.#operationTimeoutMs));
|
|
403
|
+
throwIfBackfillOtelOperationFailed(result, "export shutdown");
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function resolveObsBackfillSummary(
|
|
408
|
+
ctx: ObsBackfillCommandContext,
|
|
409
|
+
request: ObsBackfillRequest,
|
|
410
|
+
options: RegisterObsBackfillCommandOptions,
|
|
411
|
+
): Promise<ObsBackfillSummary> {
|
|
412
|
+
if (options.runBackfill) return options.runBackfill(ctx, request);
|
|
413
|
+
return runObsBackfill(ctx, request, options);
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function runObsBackfillWithScope(
|
|
417
|
+
ctx: ObsBackfillCommandContext,
|
|
418
|
+
request: ObsBackfillRequest,
|
|
419
|
+
config: ObservMeConfig,
|
|
420
|
+
operationTimeoutMs: number,
|
|
421
|
+
options: ObsBackfillOptions,
|
|
422
|
+
runScope: ObsBackfillRunScope,
|
|
423
|
+
): Promise<ObsBackfillSummary> {
|
|
424
|
+
try {
|
|
425
|
+
await waitForObsBackfillIdle(ctx, operationTimeoutMs, runScope.signal, runScope);
|
|
426
|
+
} catch (error) {
|
|
427
|
+
if (error instanceof ObsBackfillInterruptedError) return cancelledBackfillSummary(ctx, request, error.reason);
|
|
428
|
+
throw error;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const entries = readCurrentSessionEntries(ctx);
|
|
432
|
+
const sessionId = resolveCurrentSessionId(ctx);
|
|
433
|
+
const sessionFile = ctx.sessionManager?.getSessionFile?.();
|
|
434
|
+
const buildResult = buildObsBackfillRecords(entries, config, request, sessionId, options);
|
|
435
|
+
|
|
436
|
+
try {
|
|
437
|
+
throwIfBackfillAborted(runScope.signal);
|
|
438
|
+
const recordsExported = buildResult.records.length > 0 ? await exportObsBackfillRecords(buildResult.records, config, ctx, runScope.signal, operationTimeoutMs, options, runScope) : 0;
|
|
439
|
+
return completedBackfillSummary(ctx, request, buildResult, recordsExported, sessionId, sessionFile);
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error instanceof ObsBackfillInterruptedError) {
|
|
442
|
+
return interruptedBackfillSummary(ctx, request, buildResult, error.recordsExported, error.reason, sessionId, sessionFile);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
throw error;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function loadObsBackfillConfig(
|
|
450
|
+
ctx: ObsBackfillCommandContext,
|
|
451
|
+
options: ObsBackfillOptions,
|
|
452
|
+
): Promise<ObservMeConfig> {
|
|
453
|
+
return loadObsCommandConfig(ctx, options);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function resolveSkippedBackfillSummary(
|
|
457
|
+
config: ObservMeConfig,
|
|
458
|
+
ctx: ObsBackfillCommandContext,
|
|
459
|
+
request: ObsBackfillRequest,
|
|
460
|
+
): ObsBackfillSummary | undefined {
|
|
461
|
+
if (!request.currentSession) return skippedBackfillSummary(ctx, request, "only --current-session backfill is supported");
|
|
462
|
+
if (!config.enabled) return skippedBackfillSummary(ctx, request, "ObservMe is disabled");
|
|
463
|
+
if (!config.logs.enabled) return skippedBackfillSummary(ctx, request, "ObservMe log export is disabled");
|
|
464
|
+
if (!ctx.sessionManager) return skippedBackfillSummary(ctx, request, "current session state is unavailable");
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
async function confirmBackfillOrCancel(
|
|
469
|
+
ctx: ObsBackfillCommandContext,
|
|
470
|
+
request: ObsBackfillRequest,
|
|
471
|
+
config: ObservMeConfig,
|
|
472
|
+
options: ObsBackfillOptions,
|
|
473
|
+
): Promise<boolean> {
|
|
474
|
+
try {
|
|
475
|
+
return await confirmObsBackfill(ctx, request, config, options);
|
|
476
|
+
} catch (error) {
|
|
477
|
+
if (isAbortLikeError(error) || isAbortSignalAborted(ctx.signal)) return false;
|
|
478
|
+
throw error;
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function confirmObsBackfill(
|
|
483
|
+
ctx: ObsBackfillCommandContext,
|
|
484
|
+
request: ObsBackfillRequest,
|
|
485
|
+
config: ObservMeConfig,
|
|
486
|
+
options: ObsBackfillOptions,
|
|
487
|
+
): Promise<boolean> {
|
|
488
|
+
if (!canConfirmBackfill(ctx)) return false;
|
|
489
|
+
|
|
490
|
+
return ctx.ui.confirm("Confirm ObservMe backfill", buildBackfillConfirmationMessage(request, config, options), {
|
|
491
|
+
signal: ctx.signal,
|
|
492
|
+
timeout: options.confirmTimeoutMs,
|
|
493
|
+
});
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function canConfirmBackfill(ctx: ObsBackfillCommandContext): ctx is ObsBackfillCommandContext & {
|
|
497
|
+
readonly ui: ObsBackfillCommandContext["ui"] & { readonly confirm: NonNullable<ObsBackfillCommandContext["ui"]["confirm"]> };
|
|
498
|
+
} {
|
|
499
|
+
return ctx.hasUI !== false && typeof ctx.ui?.confirm === "function";
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function buildBackfillConfirmationMessage(
|
|
503
|
+
request: ObsBackfillRequest,
|
|
504
|
+
config: ObservMeConfig,
|
|
505
|
+
options: ObsBackfillOptions,
|
|
506
|
+
): string {
|
|
507
|
+
const contentMode = describeBackfillContentMode(config);
|
|
508
|
+
return [
|
|
509
|
+
"Send historical ObservMe telemetry for the current Pi session?",
|
|
510
|
+
`Window: ${request.since ?? "all current-session entries"}`,
|
|
511
|
+
`Rate limit: ${normalizeMaxRecords(options.maxRecords)} record(s)` ,
|
|
512
|
+
`Content mode: ${contentMode}`,
|
|
513
|
+
`Replayed telemetry will be marked ${COMMON_SPAN_ATTRIBUTES.OBSERVME_REPLAYED}=true.`,
|
|
514
|
+
].join("\n");
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function cancelledBackfillSummary(
|
|
518
|
+
ctx: ObsBackfillCommandContext,
|
|
519
|
+
request: ObsBackfillRequest,
|
|
520
|
+
explicitReason?: string,
|
|
521
|
+
): ObsBackfillSummary {
|
|
522
|
+
const reason = explicitReason ?? (canConfirmBackfill(ctx) ? "user did not confirm" : "interactive confirmation is required");
|
|
523
|
+
return {
|
|
524
|
+
status: "cancelled",
|
|
525
|
+
sessionId: resolveCurrentSessionId(ctx),
|
|
526
|
+
sessionFile: ctx.sessionManager?.getSessionFile?.(),
|
|
527
|
+
since: request.since,
|
|
528
|
+
entriesScanned: 0,
|
|
529
|
+
entriesEligible: 0,
|
|
530
|
+
recordsExported: 0,
|
|
531
|
+
recordsSkipped: 0,
|
|
532
|
+
rateLimited: false,
|
|
533
|
+
contentCaptured: false,
|
|
534
|
+
redactionFailures: 0,
|
|
535
|
+
reason,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function skippedBackfillSummary(
|
|
540
|
+
ctx: ObsBackfillCommandContext,
|
|
541
|
+
request: ObsBackfillRequest,
|
|
542
|
+
reason: string,
|
|
543
|
+
): ObsBackfillSummary {
|
|
544
|
+
return {
|
|
545
|
+
status: "skipped",
|
|
546
|
+
sessionId: resolveCurrentSessionId(ctx),
|
|
547
|
+
sessionFile: ctx.sessionManager?.getSessionFile?.(),
|
|
548
|
+
since: request.since,
|
|
549
|
+
entriesScanned: 0,
|
|
550
|
+
entriesEligible: 0,
|
|
551
|
+
recordsExported: 0,
|
|
552
|
+
recordsSkipped: 0,
|
|
553
|
+
rateLimited: false,
|
|
554
|
+
contentCaptured: false,
|
|
555
|
+
redactionFailures: 0,
|
|
556
|
+
reason,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function completedBackfillSummary(
|
|
561
|
+
ctx: ObsBackfillCommandContext,
|
|
562
|
+
request: ObsBackfillRequest,
|
|
563
|
+
buildResult: ObsBackfillBuildResult,
|
|
564
|
+
recordsExported: number,
|
|
565
|
+
sessionId = resolveCurrentSessionId(ctx),
|
|
566
|
+
sessionFile = ctx.sessionManager?.getSessionFile?.(),
|
|
567
|
+
): ObsBackfillSummary {
|
|
568
|
+
return backfillSummary({ status: "completed", request, buildResult, recordsExported, sessionId, sessionFile });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function interruptedBackfillSummary(
|
|
572
|
+
ctx: ObsBackfillCommandContext,
|
|
573
|
+
request: ObsBackfillRequest,
|
|
574
|
+
buildResult: ObsBackfillBuildResult,
|
|
575
|
+
recordsExported: number,
|
|
576
|
+
reason: string,
|
|
577
|
+
sessionId = resolveCurrentSessionId(ctx),
|
|
578
|
+
sessionFile = ctx.sessionManager?.getSessionFile?.(),
|
|
579
|
+
): ObsBackfillSummary {
|
|
580
|
+
return backfillSummary({ status: "cancelled", request, buildResult, recordsExported, reason, sessionId, sessionFile });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function backfillSummary(input: BackfillSummaryInput): ObsBackfillSummary {
|
|
584
|
+
return {
|
|
585
|
+
status: input.status,
|
|
586
|
+
sessionId: input.sessionId,
|
|
587
|
+
sessionFile: input.sessionFile,
|
|
588
|
+
since: input.request.since,
|
|
589
|
+
entriesScanned: input.buildResult.entriesScanned,
|
|
590
|
+
entriesEligible: input.buildResult.entriesEligible,
|
|
591
|
+
recordsExported: input.recordsExported,
|
|
592
|
+
recordsSkipped: input.buildResult.recordsSkipped + (input.buildResult.records.length - input.recordsExported),
|
|
593
|
+
rateLimited: input.buildResult.rateLimited,
|
|
594
|
+
contentCaptured: input.buildResult.contentCaptured,
|
|
595
|
+
redactionFailures: input.buildResult.redactionFailures,
|
|
596
|
+
reason: input.reason,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
async function waitForObsBackfillIdle(
|
|
601
|
+
ctx: ObsBackfillCommandContext,
|
|
602
|
+
operationTimeoutMs: number,
|
|
603
|
+
signal: AbortSignal,
|
|
604
|
+
runScope: ObsBackfillRunScope,
|
|
605
|
+
): Promise<void> {
|
|
606
|
+
if (!ctx.waitForIdle) return;
|
|
607
|
+
await runBackfillOperation(ctx.waitForIdle({ signal, timeoutMs: operationTimeoutMs }), signal, operationTimeoutMs, "wait for idle", runScope);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function exportObsBackfillRecords(
|
|
611
|
+
records: readonly ObsBackfillTelemetryRecord[],
|
|
612
|
+
config: ObservMeConfig,
|
|
613
|
+
ctx: ObsBackfillCommandContext,
|
|
614
|
+
signal: AbortSignal,
|
|
615
|
+
operationTimeoutMs: number,
|
|
616
|
+
options: ObsBackfillOptions,
|
|
617
|
+
runScope: ObsBackfillRunScope,
|
|
618
|
+
): Promise<number> {
|
|
619
|
+
const exporter = await createObsBackfillExporter(config, ctx, signal, operationTimeoutMs, options, runScope);
|
|
620
|
+
let recordsExported = 0;
|
|
621
|
+
let operationLabel = "export emit";
|
|
622
|
+
let pendingError: unknown;
|
|
623
|
+
|
|
624
|
+
try {
|
|
625
|
+
for (const record of records) {
|
|
626
|
+
throwIfBackfillAborted(signal);
|
|
627
|
+
await runBackfillOperation(exporter.emit(record, { signal, timeoutMs: operationTimeoutMs }), signal, operationTimeoutMs, operationLabel, runScope);
|
|
628
|
+
recordsExported += 1;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
operationLabel = "export flush";
|
|
632
|
+
throwIfBackfillAborted(signal);
|
|
633
|
+
await runBackfillOperation(exporter.flush?.({ signal, timeoutMs: operationTimeoutMs }), signal, operationTimeoutMs, operationLabel, runScope);
|
|
634
|
+
throwIfBackfillAborted(signal);
|
|
635
|
+
} catch (error) {
|
|
636
|
+
pendingError = backfillOperationError(error, recordsExported, operationLabel);
|
|
637
|
+
} finally {
|
|
638
|
+
pendingError = await shutdownObsBackfillExporter(exporter, signal, operationTimeoutMs, pendingError, recordsExported, runScope);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (pendingError === undefined && isAbortSignalAborted(signal)) pendingError = new ObsBackfillInterruptedError("operation cancelled", recordsExported);
|
|
642
|
+
if (pendingError !== undefined) throw pendingError;
|
|
643
|
+
return recordsExported;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
async function createObsBackfillExporter(
|
|
647
|
+
config: ObservMeConfig,
|
|
648
|
+
ctx: ObsBackfillCommandContext,
|
|
649
|
+
signal: AbortSignal,
|
|
650
|
+
operationTimeoutMs: number,
|
|
651
|
+
options: ObsBackfillOptions,
|
|
652
|
+
runScope: ObsBackfillRunScope,
|
|
653
|
+
): Promise<ObsBackfillExporter> {
|
|
654
|
+
throwIfBackfillAborted(signal);
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
const setupOptions = { signal, timeoutMs: operationTimeoutMs };
|
|
658
|
+
const exporter = options.createExporter ? options.createExporter(config, ctx, setupOptions) : createObsBackfillLogExporter(config, setupOptions);
|
|
659
|
+
return await runBackfillOperation(exporter, signal, operationTimeoutMs, "exporter setup", runScope);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
throw backfillOperationError(error, 0, "exporter setup");
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
async function shutdownObsBackfillExporter(
|
|
666
|
+
exporter: ObsBackfillExporter,
|
|
667
|
+
signal: AbortSignal,
|
|
668
|
+
operationTimeoutMs: number,
|
|
669
|
+
pendingError: unknown,
|
|
670
|
+
recordsExported: number,
|
|
671
|
+
runScope: ObsBackfillRunScope,
|
|
672
|
+
): Promise<unknown> {
|
|
673
|
+
try {
|
|
674
|
+
await runBackfillOperation(exporter.shutdown?.({ signal, timeoutMs: operationTimeoutMs }), undefined, operationTimeoutMs, "export shutdown", runScope);
|
|
675
|
+
return pendingError;
|
|
676
|
+
} catch (shutdownError) {
|
|
677
|
+
return pendingError ?? backfillOperationError(shutdownError, recordsExported, "export shutdown");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
async function runBackfillOperation<T>(
|
|
682
|
+
operation: Promise<T> | T,
|
|
683
|
+
signal: AbortSignal | undefined,
|
|
684
|
+
timeoutMs: number,
|
|
685
|
+
label: string,
|
|
686
|
+
runScope?: ObsBackfillRunScope,
|
|
687
|
+
): Promise<T> {
|
|
688
|
+
throwIfBackfillAborted(signal);
|
|
689
|
+
|
|
690
|
+
const operationPromise = Promise.resolve(operation);
|
|
691
|
+
const abortPromise = createBackfillAbortPromise<T>(signal);
|
|
692
|
+
const timeoutPromise = createBackfillTimeoutPromise<T>(timeoutMs, label);
|
|
693
|
+
|
|
694
|
+
try {
|
|
695
|
+
return await Promise.race([operationPromise, abortPromise.promise, timeoutPromise.promise]);
|
|
696
|
+
} catch (error) {
|
|
697
|
+
if (isTimedOutBackfillInterruption(error)) runScope?.abort(error.reason);
|
|
698
|
+
if (shouldWaitForBackfillOperationCleanup(error)) await waitForBackfillOperationCleanup(operationPromise, timeoutMs);
|
|
699
|
+
|
|
700
|
+
throw error;
|
|
701
|
+
} finally {
|
|
702
|
+
abortPromise.cleanup();
|
|
703
|
+
timeoutPromise.cleanup();
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function createBackfillAbortPromise<T>(signal: AbortSignal | undefined): { readonly promise: Promise<T>; readonly cleanup: () => void } {
|
|
708
|
+
if (!signal) return { promise: new Promise<T>(() => { /* never resolves */ }), cleanup: noop };
|
|
709
|
+
|
|
710
|
+
let cleanup = noop;
|
|
711
|
+
const promise = new Promise<T>((_resolve, reject) => {
|
|
712
|
+
const abortOperation = (): void => reject(new ObsBackfillInterruptedError("operation cancelled"));
|
|
713
|
+
signal.addEventListener("abort", abortOperation, { once: true });
|
|
714
|
+
cleanup = () => signal.removeEventListener("abort", abortOperation);
|
|
715
|
+
});
|
|
716
|
+
return { promise, cleanup };
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function createBackfillTimeoutPromise<T>(timeoutMs: number, label: string): { readonly promise: Promise<T>; readonly cleanup: () => void } {
|
|
720
|
+
let timeout: NodeJS.Timeout | undefined;
|
|
721
|
+
const promise = new Promise<T>((_resolve, reject) => {
|
|
722
|
+
timeout = setTimeout(() => reject(new ObsBackfillInterruptedError(`${label} timed out`, 0, true)), timeoutMs);
|
|
723
|
+
});
|
|
724
|
+
return { promise, cleanup: () => clearTimeout(timeout) };
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
async function waitForBackfillOperationCleanup<T>(operationPromise: Promise<T>, timeoutMs: number): Promise<void> {
|
|
728
|
+
const cleanupTimeoutMs = Math.min(normalizeBackfillOperationTimeoutMs(timeoutMs), OBS_BACKFILL_ABORT_CLEANUP_TIMEOUT_MS);
|
|
729
|
+
await Promise.race([operationPromise.then(noop, noop), delay(cleanupTimeoutMs, undefined, { ref: false })]);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function backfillOperationError(error: unknown, recordsExported: number, label: string): ObsBackfillInterruptedError {
|
|
733
|
+
if (error instanceof ObsBackfillInterruptedError) return new ObsBackfillInterruptedError(error.reason, recordsExported, error.timedOut);
|
|
734
|
+
if (isAbortLikeError(error)) return new ObsBackfillInterruptedError("operation cancelled", recordsExported);
|
|
735
|
+
return new ObsBackfillInterruptedError(`${label} failed`, recordsExported);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function isTimedOutBackfillInterruption(error: unknown): error is ObsBackfillInterruptedError {
|
|
739
|
+
return error instanceof ObsBackfillInterruptedError && error.timedOut;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function shouldWaitForBackfillOperationCleanup(error: unknown): boolean {
|
|
743
|
+
return error instanceof ObsBackfillInterruptedError || isAbortLikeError(error);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function throwIfBackfillOtelOperationFailed(result: BoundedOtelOperationResult, label: string): void {
|
|
747
|
+
if (result.timedOut) throw new ObsBackfillInterruptedError(`${label} timed out`, 0, true);
|
|
748
|
+
if (result.error) throw result.error;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function readCurrentSessionEntries(ctx: ObsBackfillCommandContext): readonly SessionEntry[] {
|
|
752
|
+
const entries = ctx.sessionManager?.getBranch?.() ?? ctx.sessionManager?.getEntries?.();
|
|
753
|
+
if (!entries) throw new Error("current session entries are unavailable");
|
|
754
|
+
return entries;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function resolveCurrentSessionId(ctx: ObsBackfillCommandContext): string | undefined {
|
|
758
|
+
return normalizeOptionalString(ctx.sessionManager?.getSessionId?.()) ?? normalizeOptionalString(ctx.sessionManager?.getHeader?.()?.id);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function parseObsBackfillArgs(args: string): ParsedObsBackfillArgs {
|
|
762
|
+
const parsed = parseObsSubcommandArgs(args, OBS_BACKFILL_SUBCOMMAND);
|
|
763
|
+
if (!parsed.matched) return { error: undefined };
|
|
764
|
+
return parseObsBackfillFlags(parsed.values);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function parseObsBackfillFlags(tokens: readonly string[]): ParsedObsBackfillArgs {
|
|
768
|
+
const state: ObsBackfillFlagState = { currentSession: false, index: 0 };
|
|
769
|
+
|
|
770
|
+
while (state.index < tokens.length) {
|
|
771
|
+
const result = parseObsBackfillFlag(tokens, state);
|
|
772
|
+
if (result.error) return result;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
if (!state.currentSession) return { error: "Backfill requires --current-session so historical replay is always explicit." };
|
|
776
|
+
return parsedObsBackfillRequest(state.currentSession, state.since);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
function parseObsBackfillFlag(tokens: readonly string[], state: ObsBackfillFlagState): ParsedObsBackfillArgs {
|
|
780
|
+
const token = tokens[state.index];
|
|
781
|
+
if (token === "--current-session") return parseCurrentSessionBackfillFlag(state);
|
|
782
|
+
if (token === "--since") return parseSeparateSinceBackfillFlag(tokens, state);
|
|
783
|
+
if (token.startsWith("--since=")) return parseInlineSinceBackfillFlag(token, state);
|
|
784
|
+
return { error: unknownObsOptionMessage(token) };
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function parseCurrentSessionBackfillFlag(state: ObsBackfillFlagState): ParsedObsBackfillArgs {
|
|
788
|
+
if (state.currentSession) return { error: "Repeated option: --current-session." };
|
|
789
|
+
state.currentSession = true;
|
|
790
|
+
state.index += 1;
|
|
791
|
+
return {};
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function parseSeparateSinceBackfillFlag(tokens: readonly string[], state: ObsBackfillFlagState): ParsedObsBackfillArgs {
|
|
795
|
+
if (state.since !== undefined) return { error: "Repeated option: --since." };
|
|
796
|
+
|
|
797
|
+
const value = tokens[state.index + 1];
|
|
798
|
+
if (!value || value.startsWith("--")) return { error: missingObsOptionValueMessage("--since") };
|
|
799
|
+
state.since = value;
|
|
800
|
+
state.index += 2;
|
|
801
|
+
return {};
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function parseInlineSinceBackfillFlag(token: string, state: ObsBackfillFlagState): ParsedObsBackfillArgs {
|
|
805
|
+
if (state.since !== undefined) return { error: "Repeated option: --since." };
|
|
806
|
+
|
|
807
|
+
state.since = token.slice("--since=".length);
|
|
808
|
+
if (!state.since) return { error: missingObsOptionValueMessage("--since") };
|
|
809
|
+
state.index += 1;
|
|
810
|
+
return {};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function parsedObsBackfillRequest(currentSession: boolean, since: string | undefined): ParsedObsBackfillArgs {
|
|
814
|
+
if (since === undefined) return { request: { currentSession } };
|
|
815
|
+
|
|
816
|
+
const sinceMs = parseSinceDurationMs(since);
|
|
817
|
+
if (sinceMs === undefined) return { error: `Invalid --since duration: ${since}. ${OBS_BACKFILL_SINCE_HELP}` };
|
|
818
|
+
return { request: { currentSession, since, sinceMs } };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
function parseSinceDurationMs(value: string): number | undefined {
|
|
822
|
+
const match = sincePattern.exec(value.trim());
|
|
823
|
+
if (!match) return undefined;
|
|
824
|
+
|
|
825
|
+
const amount = Number(match[1]);
|
|
826
|
+
if (!Number.isSafeInteger(amount) || amount <= 0) return undefined;
|
|
827
|
+
|
|
828
|
+
const unitMs = millisecondsForSinceUnit(match[2].toLowerCase());
|
|
829
|
+
if (amount > Math.floor(OBS_BACKFILL_MAX_SINCE_MS / unitMs)) return undefined;
|
|
830
|
+
|
|
831
|
+
const durationMs = amount * unitMs;
|
|
832
|
+
if (!Number.isSafeInteger(durationMs) || durationMs > OBS_BACKFILL_MAX_SINCE_MS) return undefined;
|
|
833
|
+
return durationMs;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function millisecondsForSinceUnit(unit: string): number {
|
|
837
|
+
if (unit === "ms") return 1;
|
|
838
|
+
if (unit === "s") return millisecondsPerSecond;
|
|
839
|
+
if (unit === "m") return millisecondsPerMinute;
|
|
840
|
+
if (unit === "h") return millisecondsPerHour;
|
|
841
|
+
return millisecondsPerDay;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function resolveSinceCutoffMs(
|
|
845
|
+
request: ObsBackfillRequest,
|
|
846
|
+
options: Pick<ObsBackfillOptions, "now">,
|
|
847
|
+
): number | undefined {
|
|
848
|
+
if (request.sinceMs === undefined) return undefined;
|
|
849
|
+
const now = options.now?.() ?? new Date();
|
|
850
|
+
return now.getTime() - request.sinceMs;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
function entryIsWithinSince(entry: SessionEntry, cutoffMs: number | undefined): boolean {
|
|
854
|
+
if (cutoffMs === undefined) return true;
|
|
855
|
+
|
|
856
|
+
const timestampMs = Date.parse(entry.timestamp);
|
|
857
|
+
return Number.isFinite(timestampMs) && timestampMs >= cutoffMs;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function sessionEntryToBackfillRecord(
|
|
861
|
+
entry: SessionEntry,
|
|
862
|
+
config: ObservMeConfig,
|
|
863
|
+
sessionId: string | undefined,
|
|
864
|
+
): { readonly record?: ObsBackfillTelemetryRecord; readonly contentCaptured: boolean; readonly redactionFailures: number } {
|
|
865
|
+
const baseAttributes = buildBaseEntryAttributes(entry, config, sessionId);
|
|
866
|
+
|
|
867
|
+
if (entry.type === "message") return messageEntryToBackfillRecord(entry, config, baseAttributes);
|
|
868
|
+
if (entry.type === "model_change") return modelChangeEntryToBackfillRecord(entry, baseAttributes);
|
|
869
|
+
if (entry.type === "thinking_level_change") return thinkingChangeEntryToBackfillRecord(entry, baseAttributes);
|
|
870
|
+
if (entry.type === "compaction") return compactionEntryToBackfillRecord(entry, baseAttributes);
|
|
871
|
+
if (entry.type === "branch_summary") return branchSummaryEntryToBackfillRecord(entry, baseAttributes);
|
|
872
|
+
return { contentCaptured: false, redactionFailures: 0 };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function buildBaseEntryAttributes(entry: SessionEntry, config: ObservMeConfig, sessionId: string | undefined): ObsBackfillAttributes {
|
|
876
|
+
return withoutUndefinedAttributes({
|
|
877
|
+
[COMMON_SPAN_ATTRIBUTES.PI_SESSION_ID]: sessionId,
|
|
878
|
+
[COMMON_SPAN_ATTRIBUTES.PI_ENTRY_ID]: entry.id,
|
|
879
|
+
[COMMON_SPAN_ATTRIBUTES.PI_ENTRY_PARENT_ID]: entry.parentId ?? undefined,
|
|
880
|
+
[COMMON_SPAN_ATTRIBUTES.PI_ENTRY_TYPE]: entry.type,
|
|
881
|
+
[COMMON_SPAN_ATTRIBUTES.OBSERVME_REPLAYED]: true,
|
|
882
|
+
[COMMON_SPAN_ATTRIBUTES.OBSERVME_CAPTURE_PROMPTS]: config.capture.prompts,
|
|
883
|
+
[COMMON_SPAN_ATTRIBUTES.OBSERVME_CAPTURE_RESPONSES]: config.capture.responses,
|
|
884
|
+
[COMMON_SPAN_ATTRIBUTES.OBSERVME_CAPTURE_TOOL_ARGUMENTS]: config.capture.toolArguments,
|
|
885
|
+
[COMMON_SPAN_ATTRIBUTES.OBSERVME_REDACTION_ENABLED]: config.privacy.redactionEnabled,
|
|
886
|
+
});
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function messageEntryToBackfillRecord(
|
|
890
|
+
entry: Extract<SessionEntry, { type: "message" }>,
|
|
891
|
+
config: ObservMeConfig,
|
|
892
|
+
baseAttributes: ObsBackfillAttributes,
|
|
893
|
+
): { readonly record?: ObsBackfillTelemetryRecord; readonly contentCaptured: boolean; readonly redactionFailures: number } {
|
|
894
|
+
const message = entry.message as unknown as Record<string, unknown>;
|
|
895
|
+
const role = readString(message, "role");
|
|
896
|
+
|
|
897
|
+
if (role === "user") return userMessageToBackfillRecord(entry, message, config, baseAttributes);
|
|
898
|
+
if (role === "assistant") return assistantMessageToBackfillRecord(entry, message, config, baseAttributes);
|
|
899
|
+
if (role === "toolResult") return toolResultMessageToBackfillRecord(entry, message, config, baseAttributes);
|
|
900
|
+
if (role === "bashExecution") return bashExecutionMessageToBackfillRecord(entry, message, config, baseAttributes);
|
|
901
|
+
return unknownMessageToBackfillRecord(entry, role, baseAttributes);
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
function userMessageToBackfillRecord(
|
|
905
|
+
entry: SessionEntry,
|
|
906
|
+
message: Record<string, unknown>,
|
|
907
|
+
config: ObservMeConfig,
|
|
908
|
+
baseAttributes: ObsBackfillAttributes,
|
|
909
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: boolean; readonly redactionFailures: number } {
|
|
910
|
+
const attributes: ObsBackfillAttributes = { ...baseAttributes, [MESSAGE_ATTRIBUTES.PI_MESSAGE_ROLE]: "user" };
|
|
911
|
+
const content = extractTextContent(readUnknown(message, "content"));
|
|
912
|
+
attributes[MESSAGE_ATTRIBUTES.PI_MESSAGE_CONTENT_LENGTH] = content?.length ?? 0;
|
|
913
|
+
const contentResult = maybeAttachCapturedContent(attributes, LLM_ATTRIBUTES.PI_LLM_PROMPT_REDACTED, content, "prompt", config, config.capture.prompts);
|
|
914
|
+
|
|
915
|
+
return {
|
|
916
|
+
record: createBackfillRecord(LOG_EVENT_NAMES.LLM_PROMPT_CAPTURED, entry, attributes),
|
|
917
|
+
contentCaptured: contentResult.captured,
|
|
918
|
+
redactionFailures: contentResult.redactionFailures,
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function assistantMessageToBackfillRecord(
|
|
923
|
+
entry: SessionEntry,
|
|
924
|
+
message: Record<string, unknown>,
|
|
925
|
+
config: ObservMeConfig,
|
|
926
|
+
baseAttributes: ObsBackfillAttributes,
|
|
927
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: boolean; readonly redactionFailures: number } {
|
|
928
|
+
const attributes = assistantMessageAttributes(message, baseAttributes);
|
|
929
|
+
const content = readUnknown(message, "content");
|
|
930
|
+
const responseResult = maybeAttachCapturedContent(
|
|
931
|
+
attributes,
|
|
932
|
+
LLM_ATTRIBUTES.PI_LLM_RESPONSE_REDACTED,
|
|
933
|
+
extractTextContent(content),
|
|
934
|
+
"response",
|
|
935
|
+
config,
|
|
936
|
+
config.capture.responses,
|
|
937
|
+
);
|
|
938
|
+
const thinkingResult = maybeAttachCapturedContent(
|
|
939
|
+
attributes,
|
|
940
|
+
LLM_ATTRIBUTES.PI_LLM_THINKING_REDACTED,
|
|
941
|
+
extractThinkingContent(content),
|
|
942
|
+
"response",
|
|
943
|
+
config,
|
|
944
|
+
config.capture.thinking,
|
|
945
|
+
);
|
|
946
|
+
const toolArgsResult = maybeAttachCapturedContent(
|
|
947
|
+
attributes,
|
|
948
|
+
TOOL_ATTRIBUTES.PI_TOOL_ARGUMENTS_REDACTED,
|
|
949
|
+
extractToolCallArgumentsContent(content),
|
|
950
|
+
"toolArgument",
|
|
951
|
+
config,
|
|
952
|
+
config.capture.toolArguments,
|
|
953
|
+
);
|
|
954
|
+
|
|
955
|
+
return {
|
|
956
|
+
record: createBackfillRecord(LOG_EVENT_NAMES.LLM_REQUEST_COMPLETED, entry, attributes),
|
|
957
|
+
contentCaptured: responseResult.captured || thinkingResult.captured || toolArgsResult.captured,
|
|
958
|
+
redactionFailures: responseResult.redactionFailures + thinkingResult.redactionFailures + toolArgsResult.redactionFailures,
|
|
959
|
+
};
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
function assistantMessageAttributes(message: Record<string, unknown>, baseAttributes: ObsBackfillAttributes): ObsBackfillAttributes {
|
|
963
|
+
const content = readUnknown(message, "content");
|
|
964
|
+
const usage = readRecord(message, "usage");
|
|
965
|
+
const cost = readRecord(usage, "cost");
|
|
966
|
+
|
|
967
|
+
return withoutUndefinedAttributes({
|
|
968
|
+
...baseAttributes,
|
|
969
|
+
[MESSAGE_ATTRIBUTES.PI_MESSAGE_ROLE]: "assistant",
|
|
970
|
+
[LLM_ATTRIBUTES.GEN_AI_PROVIDER_NAME]: readString(message, "provider"),
|
|
971
|
+
[LLM_ATTRIBUTES.GEN_AI_REQUEST_MODEL]: readString(message, "model"),
|
|
972
|
+
[LLM_ATTRIBUTES.GEN_AI_RESPONSE_MODEL]: readString(message, "responseModel"),
|
|
973
|
+
[LLM_ATTRIBUTES.GEN_AI_RESPONSE_ID]: readString(message, "responseId"),
|
|
974
|
+
[LLM_ATTRIBUTES.PI_LLM_API]: readString(message, "api"),
|
|
975
|
+
[LLM_ATTRIBUTES.PI_LLM_STOP_REASON]: readString(message, "stopReason"),
|
|
976
|
+
[LLM_ATTRIBUTES.GEN_AI_USAGE_INPUT_TOKENS]: readNumber(usage, "input"),
|
|
977
|
+
[LLM_ATTRIBUTES.GEN_AI_USAGE_OUTPUT_TOKENS]: readNumber(usage, "output"),
|
|
978
|
+
[LLM_ATTRIBUTES.GEN_AI_USAGE_CACHE_READ_INPUT_TOKENS]: readNumber(usage, "cacheRead"),
|
|
979
|
+
[LLM_ATTRIBUTES.GEN_AI_USAGE_CACHE_CREATION_INPUT_TOKENS]: readNumber(usage, "cacheWrite"),
|
|
980
|
+
[LLM_ATTRIBUTES.GEN_AI_USAGE_REASONING_OUTPUT_TOKENS]: readNumber(usage, "reasoning"),
|
|
981
|
+
[LLM_ATTRIBUTES.PI_LLM_USAGE_TOTAL_TOKENS]: readNumber(usage, "totalTokens"),
|
|
982
|
+
[LLM_ATTRIBUTES.PI_LLM_COST_TOTAL_USD]: readNumber(cost, "total"),
|
|
983
|
+
[LLM_ATTRIBUTES.PI_LLM_TOOL_CALL_COUNT]: extractToolCalls(content).length,
|
|
984
|
+
[MESSAGE_ATTRIBUTES.PI_MESSAGE_CONTENT_LENGTH]: extractTextContent(content)?.length ?? 0,
|
|
985
|
+
});
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
function toolResultMessageToBackfillRecord(
|
|
989
|
+
entry: SessionEntry,
|
|
990
|
+
message: Record<string, unknown>,
|
|
991
|
+
config: ObservMeConfig,
|
|
992
|
+
baseAttributes: ObsBackfillAttributes,
|
|
993
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: boolean; readonly redactionFailures: number } {
|
|
994
|
+
const failed = readBoolean(message, "isError") === true;
|
|
995
|
+
const attributes = withoutUndefinedAttributes({
|
|
996
|
+
...baseAttributes,
|
|
997
|
+
[MESSAGE_ATTRIBUTES.PI_MESSAGE_ROLE]: "toolResult",
|
|
998
|
+
[TOOL_ATTRIBUTES.PI_TOOL_CALL_ID]: readString(message, "toolCallId"),
|
|
999
|
+
[TOOL_ATTRIBUTES.PI_TOOL_NAME]: readString(message, "toolName"),
|
|
1000
|
+
[TOOL_ATTRIBUTES.PI_TOOL_SUCCESS]: !failed,
|
|
1001
|
+
});
|
|
1002
|
+
const content = extractTextContent(readUnknown(message, "content"));
|
|
1003
|
+
attributes[TOOL_ATTRIBUTES.PI_TOOL_RESULT_SIZE] = content?.length ?? 0;
|
|
1004
|
+
const contentResult = maybeAttachCapturedContent(attributes, TOOL_ATTRIBUTES.PI_TOOL_RESULT_REDACTED, content, "toolResult", config, config.capture.toolResults);
|
|
1005
|
+
const eventName = failed ? LOG_EVENT_NAMES.TOOL_CALL_FAILED : LOG_EVENT_NAMES.TOOL_CALL_COMPLETED;
|
|
1006
|
+
|
|
1007
|
+
return {
|
|
1008
|
+
record: createBackfillRecord(eventName, entry, attributes),
|
|
1009
|
+
contentCaptured: contentResult.captured,
|
|
1010
|
+
redactionFailures: contentResult.redactionFailures,
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
function bashExecutionMessageToBackfillRecord(
|
|
1015
|
+
entry: SessionEntry,
|
|
1016
|
+
message: Record<string, unknown>,
|
|
1017
|
+
config: ObservMeConfig,
|
|
1018
|
+
baseAttributes: ObsBackfillAttributes,
|
|
1019
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: boolean; readonly redactionFailures: number } {
|
|
1020
|
+
const command = readString(message, "command");
|
|
1021
|
+
const output = readString(message, "output");
|
|
1022
|
+
const attributes = withoutUndefinedAttributes({
|
|
1023
|
+
...baseAttributes,
|
|
1024
|
+
[MESSAGE_ATTRIBUTES.PI_MESSAGE_ROLE]: "bashExecution",
|
|
1025
|
+
[BASH_ATTRIBUTES.PI_BASH_EXIT_CODE]: readNumber(message, "exitCode"),
|
|
1026
|
+
[BASH_ATTRIBUTES.PI_BASH_CANCELLED]: readBoolean(message, "cancelled"),
|
|
1027
|
+
[BASH_ATTRIBUTES.PI_BASH_TRUNCATED]: readBoolean(message, "truncated"),
|
|
1028
|
+
[BASH_ATTRIBUTES.PI_BASH_OUTPUT_SIZE]: output?.length ?? 0,
|
|
1029
|
+
[BASH_ATTRIBUTES.PI_BASH_FULL_OUTPUT_PATH_PRESENT]: Boolean(readString(message, "fullOutputPath")),
|
|
1030
|
+
[BASH_ATTRIBUTES.PI_BASH_EXCLUDE_FROM_CONTEXT]: readBoolean(message, "excludeFromContext"),
|
|
1031
|
+
});
|
|
1032
|
+
const commandResult = maybeAttachCapturedContent(attributes, BASH_ATTRIBUTES.PI_BASH_COMMAND_REDACTED, command, "logBody", config, config.capture.bashCommands);
|
|
1033
|
+
const outputResult = maybeAttachCapturedContent(attributes, BASH_ATTRIBUTES.PI_BASH_OUTPUT_REDACTED, output, "bashOutput", config, config.capture.bashOutput);
|
|
1034
|
+
|
|
1035
|
+
return {
|
|
1036
|
+
record: createBackfillRecord(LOG_EVENT_NAMES.BASH_COMPLETED, entry, attributes),
|
|
1037
|
+
contentCaptured: commandResult.captured || outputResult.captured,
|
|
1038
|
+
redactionFailures: commandResult.redactionFailures + outputResult.redactionFailures,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function unknownMessageToBackfillRecord(
|
|
1043
|
+
entry: SessionEntry,
|
|
1044
|
+
role: string | undefined,
|
|
1045
|
+
baseAttributes: ObsBackfillAttributes,
|
|
1046
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: false; readonly redactionFailures: 0 } {
|
|
1047
|
+
const attributes = withoutUndefinedAttributes({ ...baseAttributes, [MESSAGE_ATTRIBUTES.PI_MESSAGE_ROLE]: role ?? "unknown" });
|
|
1048
|
+
return {
|
|
1049
|
+
record: createBackfillRecord(LOG_EVENT_NAMES.MESSAGE_REPLAYED, entry, attributes),
|
|
1050
|
+
contentCaptured: false,
|
|
1051
|
+
redactionFailures: 0,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function modelChangeEntryToBackfillRecord(
|
|
1056
|
+
entry: Extract<SessionEntry, { type: "model_change" }>,
|
|
1057
|
+
baseAttributes: ObsBackfillAttributes,
|
|
1058
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: false; readonly redactionFailures: 0 } {
|
|
1059
|
+
const attributes = withoutUndefinedAttributes({
|
|
1060
|
+
...baseAttributes,
|
|
1061
|
+
[LLM_ATTRIBUTES.GEN_AI_PROVIDER_NAME]: entry.provider,
|
|
1062
|
+
[LLM_ATTRIBUTES.GEN_AI_REQUEST_MODEL]: entry.modelId,
|
|
1063
|
+
});
|
|
1064
|
+
return {
|
|
1065
|
+
record: createBackfillRecord(LOG_EVENT_NAMES.MODEL_CHANGED, entry, attributes),
|
|
1066
|
+
contentCaptured: false,
|
|
1067
|
+
redactionFailures: 0,
|
|
1068
|
+
};
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function thinkingChangeEntryToBackfillRecord(
|
|
1072
|
+
entry: Extract<SessionEntry, { type: "thinking_level_change" }>,
|
|
1073
|
+
baseAttributes: ObsBackfillAttributes,
|
|
1074
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: false; readonly redactionFailures: 0 } {
|
|
1075
|
+
const attributes = withoutUndefinedAttributes({ ...baseAttributes, [LLM_ATTRIBUTES.PI_LLM_REQUEST_THINKING_LEVEL]: entry.thinkingLevel });
|
|
1076
|
+
return {
|
|
1077
|
+
record: createBackfillRecord(LOG_EVENT_NAMES.THINKING_CHANGED, entry, attributes),
|
|
1078
|
+
contentCaptured: false,
|
|
1079
|
+
redactionFailures: 0,
|
|
1080
|
+
};
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function compactionEntryToBackfillRecord(
|
|
1084
|
+
entry: Extract<SessionEntry, { type: "compaction" }>,
|
|
1085
|
+
baseAttributes: ObsBackfillAttributes,
|
|
1086
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: false; readonly redactionFailures: 0 } {
|
|
1087
|
+
const attributes = withoutUndefinedAttributes({
|
|
1088
|
+
...baseAttributes,
|
|
1089
|
+
[COMPACTION_ATTRIBUTES.PI_COMPACTION_FIRST_KEPT_ENTRY_ID]: entry.firstKeptEntryId,
|
|
1090
|
+
[COMPACTION_ATTRIBUTES.PI_COMPACTION_TOKENS_BEFORE]: entry.tokensBefore,
|
|
1091
|
+
[COMPACTION_ATTRIBUTES.PI_COMPACTION_SUMMARY_LENGTH]: entry.summary.length,
|
|
1092
|
+
[COMPACTION_ATTRIBUTES.PI_COMPACTION_FROM_HOOK]: entry.fromHook === true,
|
|
1093
|
+
});
|
|
1094
|
+
return {
|
|
1095
|
+
record: createBackfillRecord(LOG_EVENT_NAMES.COMPACTION_CREATED, entry, attributes),
|
|
1096
|
+
contentCaptured: false,
|
|
1097
|
+
redactionFailures: 0,
|
|
1098
|
+
};
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
function branchSummaryEntryToBackfillRecord(
|
|
1102
|
+
entry: Extract<SessionEntry, { type: "branch_summary" }>,
|
|
1103
|
+
baseAttributes: ObsBackfillAttributes,
|
|
1104
|
+
): { readonly record: ObsBackfillTelemetryRecord; readonly contentCaptured: false; readonly redactionFailures: 0 } {
|
|
1105
|
+
const attributes = withoutUndefinedAttributes({
|
|
1106
|
+
...baseAttributes,
|
|
1107
|
+
[BRANCH_ATTRIBUTES.PI_BRANCH_FROM_ID]: entry.fromId,
|
|
1108
|
+
[BRANCH_ATTRIBUTES.PI_BRANCH_SUMMARY_LENGTH]: entry.summary.length,
|
|
1109
|
+
[BRANCH_ATTRIBUTES.PI_BRANCH_FROM_HOOK]: entry.fromHook === true,
|
|
1110
|
+
});
|
|
1111
|
+
return {
|
|
1112
|
+
record: createBackfillRecord(LOG_EVENT_NAMES.BRANCH_CREATED, entry, attributes),
|
|
1113
|
+
contentCaptured: false,
|
|
1114
|
+
redactionFailures: 0,
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function createBackfillRecord(
|
|
1119
|
+
eventName: string,
|
|
1120
|
+
entry: SessionEntry,
|
|
1121
|
+
attributes: ObsBackfillAttributes,
|
|
1122
|
+
): ObsBackfillTelemetryRecord {
|
|
1123
|
+
return {
|
|
1124
|
+
eventName,
|
|
1125
|
+
body: eventName,
|
|
1126
|
+
attributes,
|
|
1127
|
+
timestamp: parseEntryTimestamp(entry),
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
function maybeAttachCapturedContent(
|
|
1132
|
+
attributes: ObsBackfillAttributes,
|
|
1133
|
+
attributeKey: string,
|
|
1134
|
+
value: string | undefined,
|
|
1135
|
+
kind: ContentLimitKind,
|
|
1136
|
+
config: ObservMeConfig,
|
|
1137
|
+
captureEnabled: boolean,
|
|
1138
|
+
): ObsBackfillContentResult {
|
|
1139
|
+
const result = applyContentCapturePolicy({ captureEnabled, value, kind, config });
|
|
1140
|
+
if (!result.captured || result.value === undefined) return { captured: false, redactionFailures: result.redactionFailures };
|
|
1141
|
+
|
|
1142
|
+
attributes[attributeKey] = result.value;
|
|
1143
|
+
Object.assign(attributes, result.attributes);
|
|
1144
|
+
return { captured: true, redactionFailures: 0 };
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function extractTextContent(value: unknown): string | undefined {
|
|
1148
|
+
if (typeof value === "string") return value;
|
|
1149
|
+
if (!Array.isArray(value)) return undefined;
|
|
1150
|
+
|
|
1151
|
+
const parts = value.map(extractTextBlockContent).filter(isNonEmptyString);
|
|
1152
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
function extractTextBlockContent(value: unknown): string | undefined {
|
|
1156
|
+
if (!isRecord(value)) return undefined;
|
|
1157
|
+
if (readString(value, "type") !== "text") return undefined;
|
|
1158
|
+
return readString(value, "text");
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
function extractThinkingContent(value: unknown): string | undefined {
|
|
1162
|
+
if (!Array.isArray(value)) return undefined;
|
|
1163
|
+
|
|
1164
|
+
const parts = value.map(extractThinkingBlockContent).filter(isNonEmptyString);
|
|
1165
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
function extractThinkingBlockContent(value: unknown): string | undefined {
|
|
1169
|
+
if (!isRecord(value)) return undefined;
|
|
1170
|
+
if (readString(value, "type") !== "thinking") return undefined;
|
|
1171
|
+
return readString(value, "thinking");
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function extractToolCallArgumentsContent(value: unknown): string | undefined {
|
|
1175
|
+
const toolCalls = extractToolCalls(value);
|
|
1176
|
+
if (toolCalls.length === 0) return undefined;
|
|
1177
|
+
return safeJsonStringify(toolCalls);
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
function extractToolCalls(value: unknown): ToolCallContent[] {
|
|
1181
|
+
if (!Array.isArray(value)) return [];
|
|
1182
|
+
return value.map(extractToolCall).filter(isToolCallContent);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function extractToolCall(value: unknown): ToolCallContent | undefined {
|
|
1186
|
+
if (!isRecord(value)) return undefined;
|
|
1187
|
+
if (readString(value, "type") !== "toolCall") return undefined;
|
|
1188
|
+
return {
|
|
1189
|
+
name: readString(value, "name"),
|
|
1190
|
+
arguments: readUnknown(value, "arguments"),
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
function isToolCallContent(value: ToolCallContent | undefined): value is ToolCallContent {
|
|
1195
|
+
return value !== undefined;
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
function parseEntryTimestamp(entry: SessionEntry): Date | undefined {
|
|
1199
|
+
const timestampMs = Date.parse(entry.timestamp);
|
|
1200
|
+
return Number.isFinite(timestampMs) ? new Date(timestampMs) : undefined;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
function normalizeMaxRecords(value: number | undefined): number {
|
|
1204
|
+
if (value === undefined || !Number.isFinite(value) || value < 1) return OBS_BACKFILL_DEFAULT_MAX_RECORDS;
|
|
1205
|
+
return Math.trunc(value);
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
function normalizeBackfillOperationTimeoutMs(value: number | undefined): number {
|
|
1209
|
+
return normalizeObsCommandTimeoutMs(value, OBS_BACKFILL_DEFAULT_OPERATION_TIMEOUT_MS);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function resolveBackfillOperationTimeoutMs(options: ObsBackfillOperationOptions | undefined, fallback: number): number {
|
|
1213
|
+
if (options?.timeoutMs === undefined) return normalizeBackfillOperationTimeoutMs(fallback);
|
|
1214
|
+
return normalizeBackfillOperationTimeoutMs(options.timeoutMs);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function isAbortSignalAborted(signal: AbortSignal | undefined): boolean {
|
|
1218
|
+
return signal?.aborted === true;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function throwIfBackfillAborted(signal: AbortSignal | undefined): void {
|
|
1222
|
+
if (isAbortSignalAborted(signal)) throw new ObsBackfillInterruptedError("operation cancelled");
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function isAbortLikeError(error: unknown): boolean {
|
|
1226
|
+
if (!isRecord(error)) return false;
|
|
1227
|
+
const name = error["name"];
|
|
1228
|
+
return typeof name === "string" && abortErrorNames.has(name);
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
function anyBackfillContentCaptureEnabled(config: ObservMeConfig): boolean {
|
|
1232
|
+
return (
|
|
1233
|
+
config.capture.prompts ||
|
|
1234
|
+
config.capture.responses ||
|
|
1235
|
+
config.capture.thinking ||
|
|
1236
|
+
config.capture.toolArguments ||
|
|
1237
|
+
config.capture.toolResults ||
|
|
1238
|
+
config.capture.bashCommands ||
|
|
1239
|
+
config.capture.bashOutput
|
|
1240
|
+
);
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1243
|
+
function describeBackfillContentMode(config: ObservMeConfig): string {
|
|
1244
|
+
if (!anyBackfillContentCaptureEnabled(config)) return "content capture is disabled";
|
|
1245
|
+
if (!config.privacy.redactionEnabled && config.privacy.allowUnsafeCapture) return "enabled capture flags will export raw truncated content";
|
|
1246
|
+
return "enabled capture flags will be redacted before export";
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
function notificationTypeForSummary(summary: ObsBackfillSummary): "info" | "warning" {
|
|
1250
|
+
if (summary.status === "completed" && !summary.rateLimited && summary.redactionFailures === 0) return "info";
|
|
1251
|
+
return "warning";
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function renderCancelledObsBackfillSummary(summary: ObsBackfillSummary): string {
|
|
1255
|
+
const lines = [`ObservMe backfill cancelled: ${summary.reason ?? "user did not confirm"}.`];
|
|
1256
|
+
if (summary.entriesScanned > 0) lines.push(`Entries scanned: ${summary.entriesScanned}`);
|
|
1257
|
+
if (summary.entriesEligible > 0) lines.push(`Entries eligible: ${summary.entriesEligible}`);
|
|
1258
|
+
if (summary.recordsExported > 0) lines.push(`Records exported before cancellation: ${summary.recordsExported}`);
|
|
1259
|
+
if (summary.recordsSkipped > 0) lines.push(`Records not exported: ${summary.recordsSkipped}`);
|
|
1260
|
+
return lines.join("\n");
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
function withoutUndefinedAttributes(values: Record<string, ObsBackfillAttributeValue | undefined>): ObsBackfillAttributes {
|
|
1264
|
+
const attributes: ObsBackfillAttributes = {};
|
|
1265
|
+
|
|
1266
|
+
for (const [key, value] of Object.entries(values)) {
|
|
1267
|
+
if (value !== undefined) attributes[key] = value;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
return attributes;
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function readUnknown(value: unknown, key: string): unknown {
|
|
1274
|
+
if (!isRecord(value)) return undefined;
|
|
1275
|
+
return value[key];
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function readRecord(value: unknown, key: string): Record<string, unknown> | undefined {
|
|
1279
|
+
const nested = readUnknown(value, key);
|
|
1280
|
+
return isRecord(nested) ? nested : undefined;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function readString(value: unknown, key: string): string | undefined {
|
|
1284
|
+
const raw = readUnknown(value, key);
|
|
1285
|
+
return typeof raw === "string" ? raw : undefined;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function readNumber(value: unknown, key: string): number | undefined {
|
|
1289
|
+
const raw = readUnknown(value, key);
|
|
1290
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) return undefined;
|
|
1291
|
+
return raw;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function readBoolean(value: unknown, key: string): boolean | undefined {
|
|
1295
|
+
const raw = readUnknown(value, key);
|
|
1296
|
+
return typeof raw === "boolean" ? raw : undefined;
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
1300
|
+
return typeof value === "object" && value !== null;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function normalizeOptionalString(value: string | undefined): string | undefined {
|
|
1304
|
+
const trimmed = value?.trim();
|
|
1305
|
+
return trimmed || undefined;
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
function safeJsonStringify(value: unknown): string | undefined {
|
|
1309
|
+
try {
|
|
1310
|
+
const text = JSON.stringify(value);
|
|
1311
|
+
return typeof text === "string" ? text : undefined;
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
if (error instanceof TypeError) return undefined;
|
|
1314
|
+
throw error;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function isNonEmptyString(value: string | undefined): value is string {
|
|
1319
|
+
return value !== undefined && value.length > 0;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
function formatBoolean(value: boolean): string {
|
|
1323
|
+
return value ? "yes" : "no";
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
function formatBackfillError(error: unknown): string {
|
|
1327
|
+
if (error instanceof ObsBackfillInterruptedError) return error.reason;
|
|
1328
|
+
if (isAbortLikeError(error)) return "operation cancelled";
|
|
1329
|
+
return "operation failed";
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
function noop(): void {
|
|
1333
|
+
// Intentionally empty.
|
|
1334
|
+
}
|