@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,332 @@
|
|
|
1
|
+
import type { ObservMeConfig } from "../config/schema.ts";
|
|
2
|
+
import type { GrafanaFetch, GrafanaTransportClient } from "./grafana-transport.ts";
|
|
3
|
+
import { createGrafanaTransport } from "./grafana-transport.ts";
|
|
4
|
+
import { assertNoSensitiveQueryInput } from "../safety/sensitive-input.ts";
|
|
5
|
+
import { assertGrafanaQueryReady } from "./grafana-readiness.ts";
|
|
6
|
+
import {
|
|
7
|
+
AGENT_RUN_ATTRIBUTES,
|
|
8
|
+
AGENT_SPAWN_ATTRIBUTES,
|
|
9
|
+
AGENT_WAIT_JOIN_ATTRIBUTES,
|
|
10
|
+
BRANCH_ATTRIBUTES,
|
|
11
|
+
COMMON_SPAN_ATTRIBUTES,
|
|
12
|
+
LOG_ATTRIBUTES,
|
|
13
|
+
RESOURCE_ATTRIBUTES,
|
|
14
|
+
TOOL_ATTRIBUTES,
|
|
15
|
+
TURN_ATTRIBUTES,
|
|
16
|
+
} from "../semconv/attributes.ts";
|
|
17
|
+
|
|
18
|
+
export type TempoFetch = GrafanaFetch;
|
|
19
|
+
|
|
20
|
+
export interface TimeRange {
|
|
21
|
+
readonly from: Date;
|
|
22
|
+
readonly to: Date;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TraceSummary {
|
|
26
|
+
readonly traceId: string;
|
|
27
|
+
readonly rootServiceName?: string;
|
|
28
|
+
readonly rootTraceName?: string;
|
|
29
|
+
readonly startTimeUnixNano?: string;
|
|
30
|
+
readonly durationMs?: number;
|
|
31
|
+
readonly spanSet?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TempoQueryClientOptions {
|
|
35
|
+
readonly fetch?: TempoFetch;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface NormalizedTempoSearchAttribute {
|
|
39
|
+
readonly key: string;
|
|
40
|
+
readonly value: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface NormalizedTimeRange {
|
|
44
|
+
readonly from: Date;
|
|
45
|
+
readonly to: Date;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const minimumMaxTraces = 1;
|
|
49
|
+
const maxTempoSearchAttributes = 8;
|
|
50
|
+
const maxTempoSearchAttributeValueLength = 256;
|
|
51
|
+
const safeTempoAttributeKeyPattern = /^[A-Za-z_][A-Za-z0-9_.-]*$/u;
|
|
52
|
+
const safeTempoAttributeValuePattern = /^[A-Za-z0-9._:-]+$/u;
|
|
53
|
+
const hashedAttributeKeyPattern = /(?:\.hash|_hash)$/u;
|
|
54
|
+
const backslashCharacter = String.fromCodePoint(92);
|
|
55
|
+
const generatedCorrelationAttributeKeys = new Set<string>([
|
|
56
|
+
COMMON_SPAN_ATTRIBUTES.PI_SESSION_ID,
|
|
57
|
+
COMMON_SPAN_ATTRIBUTES.PI_WORKFLOW_ID,
|
|
58
|
+
COMMON_SPAN_ATTRIBUTES.PI_WORKFLOW_ROOT_AGENT_ID,
|
|
59
|
+
COMMON_SPAN_ATTRIBUTES.PI_AGENT_ID,
|
|
60
|
+
COMMON_SPAN_ATTRIBUTES.PI_AGENT_PARENT_ID,
|
|
61
|
+
COMMON_SPAN_ATTRIBUTES.PI_AGENT_ROOT_ID,
|
|
62
|
+
COMMON_SPAN_ATTRIBUTES.PI_AGENT_RUN_ID,
|
|
63
|
+
COMMON_SPAN_ATTRIBUTES.PI_ENTRY_ID,
|
|
64
|
+
COMMON_SPAN_ATTRIBUTES.PI_ENTRY_PARENT_ID,
|
|
65
|
+
RESOURCE_ATTRIBUTES.PI_WORKFLOW_ID,
|
|
66
|
+
RESOURCE_ATTRIBUTES.PI_WORKFLOW_ROOT_AGENT_ID,
|
|
67
|
+
RESOURCE_ATTRIBUTES.PI_AGENT_ID,
|
|
68
|
+
RESOURCE_ATTRIBUTES.PI_AGENT_PARENT_ID,
|
|
69
|
+
RESOURCE_ATTRIBUTES.PI_AGENT_ROOT_ID,
|
|
70
|
+
LOG_ATTRIBUTES.PI_SESSION_ID,
|
|
71
|
+
LOG_ATTRIBUTES.PI_WORKFLOW_ID,
|
|
72
|
+
LOG_ATTRIBUTES.PI_WORKFLOW_ROOT_AGENT_ID,
|
|
73
|
+
LOG_ATTRIBUTES.PI_AGENT_ID,
|
|
74
|
+
LOG_ATTRIBUTES.PI_AGENT_PARENT_ID,
|
|
75
|
+
LOG_ATTRIBUTES.PI_AGENT_ROOT_ID,
|
|
76
|
+
LOG_ATTRIBUTES.PI_AGENT_RUN_ID,
|
|
77
|
+
LOG_ATTRIBUTES.PI_TURN_ID,
|
|
78
|
+
LOG_ATTRIBUTES.TRACE_ID,
|
|
79
|
+
LOG_ATTRIBUTES.SPAN_ID,
|
|
80
|
+
AGENT_RUN_ATTRIBUTES.PI_AGENT_ID,
|
|
81
|
+
AGENT_RUN_ATTRIBUTES.PI_AGENT_PARENT_ID,
|
|
82
|
+
AGENT_RUN_ATTRIBUTES.PI_AGENT_ROOT_ID,
|
|
83
|
+
AGENT_RUN_ATTRIBUTES.PI_AGENT_RUN_ID,
|
|
84
|
+
AGENT_SPAWN_ATTRIBUTES.PI_AGENT_SPAWN_ID,
|
|
85
|
+
AGENT_SPAWN_ATTRIBUTES.PI_AGENT_CHILD_ID,
|
|
86
|
+
AGENT_SPAWN_ATTRIBUTES.PI_AGENT_PARENT_ID,
|
|
87
|
+
AGENT_SPAWN_ATTRIBUTES.PI_AGENT_ROOT_ID,
|
|
88
|
+
AGENT_SPAWN_ATTRIBUTES.PI_WORKFLOW_ID,
|
|
89
|
+
AGENT_SPAWN_ATTRIBUTES.PI_WORKFLOW_ROOT_AGENT_ID,
|
|
90
|
+
AGENT_SPAWN_ATTRIBUTES.PI_SESSION_ID,
|
|
91
|
+
AGENT_WAIT_JOIN_ATTRIBUTES.PI_WORKFLOW_ID,
|
|
92
|
+
AGENT_WAIT_JOIN_ATTRIBUTES.PI_AGENT_ID,
|
|
93
|
+
AGENT_WAIT_JOIN_ATTRIBUTES.PI_AGENT_CHILD_ID,
|
|
94
|
+
AGENT_WAIT_JOIN_ATTRIBUTES.PI_AGENT_SPAWN_ID,
|
|
95
|
+
TURN_ATTRIBUTES.PI_TURN_ID,
|
|
96
|
+
TOOL_ATTRIBUTES.PI_TOOL_CALL_ID,
|
|
97
|
+
BRANCH_ATTRIBUTES.PI_BRANCH_FROM_ID,
|
|
98
|
+
BRANCH_ATTRIBUTES.PI_BRANCH_TO_ID,
|
|
99
|
+
BRANCH_ATTRIBUTES.PI_BRANCH_COMMON_ANCESTOR_ID,
|
|
100
|
+
BRANCH_ATTRIBUTES.PI_LEAF_ID,
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
export class TempoQueryClient {
|
|
104
|
+
readonly #config: ObservMeConfig;
|
|
105
|
+
readonly #transport: GrafanaTransportClient;
|
|
106
|
+
|
|
107
|
+
constructor(config: ObservMeConfig, options: TempoQueryClientOptions = {}) {
|
|
108
|
+
this.#config = config;
|
|
109
|
+
this.#transport = createGrafanaTransport(config, options);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async searchTempo(attrs: Record<string, string>, range: TimeRange): Promise<TraceSummary[]> {
|
|
113
|
+
return searchTempoWithTransport(this.#config, this.#transport, attrs, range);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createTempoQueryClient(
|
|
118
|
+
config: ObservMeConfig,
|
|
119
|
+
options: TempoQueryClientOptions = {},
|
|
120
|
+
): TempoQueryClient {
|
|
121
|
+
return new TempoQueryClient(config, options);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export async function searchTempo(
|
|
125
|
+
config: ObservMeConfig,
|
|
126
|
+
attrs: Record<string, string>,
|
|
127
|
+
range: TimeRange,
|
|
128
|
+
options: TempoQueryClientOptions = {},
|
|
129
|
+
): Promise<TraceSummary[]> {
|
|
130
|
+
return searchTempoWithTransport(config, createGrafanaTransport(config, options), attrs, range);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function searchTempoWithTransport(
|
|
134
|
+
config: ObservMeConfig,
|
|
135
|
+
transport: GrafanaTransportClient,
|
|
136
|
+
attrs: Record<string, string>,
|
|
137
|
+
range: TimeRange,
|
|
138
|
+
): Promise<TraceSummary[]> {
|
|
139
|
+
if (!config.query.enabled) return [];
|
|
140
|
+
|
|
141
|
+
assertGrafanaQueryReady(config, "tempo");
|
|
142
|
+
const searchAttrs = normalizeTempoSearchAttributes(attrs);
|
|
143
|
+
const timeRange = normalizeTimeRange(range);
|
|
144
|
+
const maxTraces = resolveMaxTraces(config);
|
|
145
|
+
const url = createTempoSearchUrl(config, transport, searchAttrs, timeRange, maxTraces);
|
|
146
|
+
const response = await transport.fetch(url, { timeoutMessage: "Tempo search timed out." });
|
|
147
|
+
|
|
148
|
+
return readTempoTraceSummaries(response, transport, maxTraces);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function normalizeTempoSearchAttributes(attrs: Record<string, string>): NormalizedTempoSearchAttribute[] {
|
|
152
|
+
const entries = Object.entries(attrs);
|
|
153
|
+
if (entries.length === 0) throw new Error("Tempo search requires at least one safe correlation attribute.");
|
|
154
|
+
if (entries.length > maxTempoSearchAttributes) throw new Error("Tempo search is bounded to at most 8 attributes.");
|
|
155
|
+
|
|
156
|
+
return entries.map(normalizeTempoSearchAttribute).sort(compareTempoSearchAttributes);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeTempoSearchAttribute([rawKey, rawValue]: [string, string]): NormalizedTempoSearchAttribute {
|
|
160
|
+
const key = rawKey.trim();
|
|
161
|
+
const value = normalizeTempoSearchAttributeValue(rawValue, key);
|
|
162
|
+
|
|
163
|
+
assertSafeTempoAttributeKey(key);
|
|
164
|
+
assertSafeTempoAttributeValue(value, key);
|
|
165
|
+
return { key, value };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function normalizeTempoSearchAttributeValue(rawValue: string, key: string): string {
|
|
169
|
+
if (typeof rawValue !== "string") throw new Error(`Unsafe Tempo ${key}: attribute values must be strings.`);
|
|
170
|
+
|
|
171
|
+
const value = rawValue.trim();
|
|
172
|
+
if (!value) throw new Error(`Unsafe Tempo ${key}: empty values are not query inputs.`);
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function compareTempoSearchAttributes(
|
|
177
|
+
left: NormalizedTempoSearchAttribute,
|
|
178
|
+
right: NormalizedTempoSearchAttribute,
|
|
179
|
+
): number {
|
|
180
|
+
return left.key.localeCompare(right.key);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function assertSafeTempoAttributeKey(key: string): void {
|
|
184
|
+
if (!safeTempoAttributeKeyPattern.test(key)) throw new Error(`Unsafe Tempo attribute key: ${key}`);
|
|
185
|
+
if (isAllowedTempoSearchAttributeKey(key)) return;
|
|
186
|
+
|
|
187
|
+
throw new Error(
|
|
188
|
+
"Unsafe Tempo attribute key: searchTempo may only use generated workflow IDs, generated agent IDs, session IDs, trace/span IDs, or hashed fields.",
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function assertSafeTempoAttributeValue(value: string, key: string): void {
|
|
193
|
+
if (value.length > maxTempoSearchAttributeValueLength) {
|
|
194
|
+
throw new Error(`Unsafe Tempo ${key}: attribute values are bounded to 256 characters.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
assertNoSensitiveQueryInput(value, `Tempo ${key}`);
|
|
198
|
+
|
|
199
|
+
if (!safeTempoAttributeValuePattern.test(value)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Unsafe Tempo ${key}: only generated IDs and hash-like values are accepted; raw prompts, commands, paths, and environment values are rejected.`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function isAllowedTempoSearchAttributeKey(key: string): boolean {
|
|
207
|
+
return generatedCorrelationAttributeKeys.has(key) || hashedAttributeKeyPattern.test(key);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function normalizeTimeRange(range: TimeRange): NormalizedTimeRange {
|
|
211
|
+
const from = normalizeDate(range.from, "from");
|
|
212
|
+
const to = normalizeDate(range.to, "to");
|
|
213
|
+
|
|
214
|
+
if (from.getTime() > to.getTime()) throw new Error("Tempo search range must have from <= to.");
|
|
215
|
+
return { from, to };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeDate(value: Date, label: string): Date {
|
|
219
|
+
if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
|
|
220
|
+
throw new TypeError(`Tempo search range ${label} must be a valid Date.`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function createTempoSearchUrl(
|
|
227
|
+
config: ObservMeConfig,
|
|
228
|
+
transport: GrafanaTransportClient,
|
|
229
|
+
attrs: readonly NormalizedTempoSearchAttribute[],
|
|
230
|
+
range: NormalizedTimeRange,
|
|
231
|
+
maxTraces: number,
|
|
232
|
+
): URL {
|
|
233
|
+
const url = transport.datasourceProxyUrl(config.query.grafana.datasourceUids.tempo, "/api/search");
|
|
234
|
+
|
|
235
|
+
url.searchParams.set("tags", buildTempoTagsQuery(attrs));
|
|
236
|
+
url.searchParams.set("start", formatEpochSeconds(range.from, "floor"));
|
|
237
|
+
url.searchParams.set("end", formatEpochSeconds(range.to, "ceil"));
|
|
238
|
+
url.searchParams.set("limit", String(maxTraces));
|
|
239
|
+
return url;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function buildTempoTagsQuery(attrs: readonly NormalizedTempoSearchAttribute[]): string {
|
|
243
|
+
return attrs.map(formatTempoTagQueryPart).join(" ");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function formatTempoTagQueryPart(attr: NormalizedTempoSearchAttribute): string {
|
|
247
|
+
return `${attr.key}="${escapeTempoTagValue(attr.value)}"`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function escapeTempoTagValue(value: string): string {
|
|
251
|
+
return value.replaceAll(backslashCharacter, String.raw`\\`).replaceAll('"', String.raw`\"`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatEpochSeconds(date: Date, direction: "floor" | "ceil"): string {
|
|
255
|
+
const seconds = date.getTime() / 1000;
|
|
256
|
+
const rounded = direction === "floor" ? Math.floor(seconds) : Math.ceil(seconds);
|
|
257
|
+
return String(rounded);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
async function readTempoTraceSummaries(
|
|
261
|
+
response: Response,
|
|
262
|
+
transport: GrafanaTransportClient,
|
|
263
|
+
maxTraces: number,
|
|
264
|
+
): Promise<TraceSummary[]> {
|
|
265
|
+
if (!response.ok) throw new Error(`Tempo search failed: ${transport.formatHttpFailure(response)}`);
|
|
266
|
+
|
|
267
|
+
const payload = (await response.json()) as unknown;
|
|
268
|
+
const traces = extractTempoTraceItems(payload);
|
|
269
|
+
return traces.map(toTraceSummary).filter(isTraceSummary).slice(0, maxTraces);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function extractTempoTraceItems(payload: unknown): unknown[] {
|
|
273
|
+
if (Array.isArray(payload)) return payload;
|
|
274
|
+
if (isRecord(payload) && Array.isArray(payload.traces)) return payload.traces;
|
|
275
|
+
return [];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function toTraceSummary(item: unknown): TraceSummary | undefined {
|
|
279
|
+
if (!isRecord(item)) return undefined;
|
|
280
|
+
|
|
281
|
+
const traceId = readTraceId(item);
|
|
282
|
+
if (!traceId) return undefined;
|
|
283
|
+
|
|
284
|
+
return {
|
|
285
|
+
traceId,
|
|
286
|
+
rootServiceName: readOptionalString(item, "rootServiceName"),
|
|
287
|
+
rootTraceName: readOptionalString(item, "rootTraceName"),
|
|
288
|
+
startTimeUnixNano: readOptionalStringOrNumber(item, "startTimeUnixNano"),
|
|
289
|
+
durationMs: readOptionalFiniteNumber(item, "durationMs"),
|
|
290
|
+
spanSet: item.spanSet,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function readTraceId(item: Record<string, unknown>): string | undefined {
|
|
295
|
+
const traceId = readOptionalString(item, "traceID") ?? readOptionalString(item, "traceId");
|
|
296
|
+
if (!traceId) return undefined;
|
|
297
|
+
return traceId.trim().toLowerCase();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function readOptionalString(item: Record<string, unknown>, key: string): string | undefined {
|
|
301
|
+
const value = item[key];
|
|
302
|
+
if (typeof value !== "string") return undefined;
|
|
303
|
+
|
|
304
|
+
const trimmed = value.trim();
|
|
305
|
+
return trimmed || undefined;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function readOptionalStringOrNumber(item: Record<string, unknown>, key: string): string | undefined {
|
|
309
|
+
const value = item[key];
|
|
310
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
311
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function readOptionalFiniteNumber(item: Record<string, unknown>, key: string): number | undefined {
|
|
316
|
+
const value = item[key];
|
|
317
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function isTraceSummary(value: TraceSummary | undefined): value is TraceSummary {
|
|
321
|
+
return value !== undefined;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
325
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function resolveMaxTraces(config: ObservMeConfig): number {
|
|
329
|
+
const maxTraces = config.query.maxTraces;
|
|
330
|
+
if (!Number.isFinite(maxTraces) || maxTraces < minimumMaxTraces) return minimumMaxTraces;
|
|
331
|
+
return Math.trunc(maxTraces);
|
|
332
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
export interface DiagnosticReplacement {
|
|
2
|
+
readonly pattern: RegExp;
|
|
3
|
+
readonly replacement: string;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface ObservMeSessionIdOptions {
|
|
7
|
+
readonly emptyMessage: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const SENSITIVE_QUERY_INPUT_DETAILS =
|
|
11
|
+
"raw prompts, commands, paths, and inherited environment values are not query inputs.";
|
|
12
|
+
export const UNSAFE_OBSERVME_SESSION_ID_DETAILS =
|
|
13
|
+
"only generated session IDs may be used; raw prompts, commands, paths, and environment values are not query inputs.";
|
|
14
|
+
|
|
15
|
+
const maximumDiagnosticLength = 360;
|
|
16
|
+
const rawContentMarkerPattern = /(?:^|[\s"'`|=(])(?:prompt|system prompt|user prompt|assistant response|thinking|raw content)\s*:/iu;
|
|
17
|
+
const shellCommandPattern = /(?:^|[\s"'`|=])(?:sudo|rm|mv|cp|curl|wget|npm|pnpm|yarn|node|python3?|bash|sh|git)\s+\S+/iu;
|
|
18
|
+
const homeFilesystemPathPattern = /(?:^|[\s"'`=])~(?:\/|\b)\S*/u;
|
|
19
|
+
const relativeFilesystemPathPattern = /(?:^|[\s"'`=])\.{1,2}\/\S*/u;
|
|
20
|
+
const unixFilesystemPathPattern = /(?:^|[\s"'`=])\/(?:Users|home|tmp|var|etc|private|workspace|opt|Volumes)\b\S*/u;
|
|
21
|
+
const windowsDriveFilesystemPathPattern = /(?:^|[\s"'`=])[A-Za-z]:\\\S*/u;
|
|
22
|
+
const windowsUncFilesystemPathPattern = /(?:^|[\s"'`=])\\\\\S*/u;
|
|
23
|
+
const environmentAssignmentPattern = /\b[A-Z][A-Z0-9_]{2,}=[^\s"'`;,)]*/u;
|
|
24
|
+
const diagnosticEnvironmentAssignmentPattern = /\b[A-Z][A-Z0-9_]{2,}=[^\s"'`;,)]*/gu;
|
|
25
|
+
const unresolvedEnvironmentPlaceholderPattern = /\$\{[A-Z0-9_]+\}/u;
|
|
26
|
+
const diagnosticUnresolvedEnvironmentPlaceholderPattern = /\$\{[A-Z0-9_]+\}/gu;
|
|
27
|
+
const bearerCredentialPattern = /\bBearer\s+[^\s"'`;,)]+/iu;
|
|
28
|
+
const credentialAssignmentPatterns = [
|
|
29
|
+
/\baccess[_-]?token\s*[:=]\s*["']?[^\s"'`;,)]*/iu,
|
|
30
|
+
/\bapi[_-]?key\s*[:=]\s*["']?[^\s"'`;,)]*/iu,
|
|
31
|
+
/\btoken\s*[:=]\s*["']?[^\s"'`;,)]*/iu,
|
|
32
|
+
/\bpassword\s*[:=]\s*["']?[^\s"'`;,)]*/iu,
|
|
33
|
+
/\bsecret\s*[:=]\s*["']?[^\s"'`;,)]*/iu,
|
|
34
|
+
/\bauthorization\s*[:=]\s*["']?[^\s"'`;,)]*/iu,
|
|
35
|
+
] as const;
|
|
36
|
+
const safeObservMeSessionIdPattern = /^[A-Za-z0-9._:-]{1,256}$/u;
|
|
37
|
+
const sensitiveQueryInputPatterns = [
|
|
38
|
+
rawContentMarkerPattern,
|
|
39
|
+
shellCommandPattern,
|
|
40
|
+
homeFilesystemPathPattern,
|
|
41
|
+
relativeFilesystemPathPattern,
|
|
42
|
+
unixFilesystemPathPattern,
|
|
43
|
+
windowsDriveFilesystemPathPattern,
|
|
44
|
+
windowsUncFilesystemPathPattern,
|
|
45
|
+
environmentAssignmentPattern,
|
|
46
|
+
unresolvedEnvironmentPlaceholderPattern,
|
|
47
|
+
bearerCredentialPattern,
|
|
48
|
+
...credentialAssignmentPatterns,
|
|
49
|
+
] as const;
|
|
50
|
+
const diagnosticReplacements = [
|
|
51
|
+
{ pattern: /Bearer\s+[^\s;,)]+/giu, replacement: "Bearer [redacted]" },
|
|
52
|
+
{ pattern: /Basic\s+[^\s;,)]+/giu, replacement: "Basic [redacted]" },
|
|
53
|
+
{ pattern: /\b(token|password|secret|authorization)\s*[:=]\s*["']?[^"'\s;,)]+/giu, replacement: "$1=[redacted]" },
|
|
54
|
+
{ pattern: diagnosticEnvironmentAssignmentPattern, replacement: "[redacted-env]" },
|
|
55
|
+
{ pattern: diagnosticUnresolvedEnvironmentPlaceholderPattern, replacement: "[redacted-env-placeholder]" },
|
|
56
|
+
{
|
|
57
|
+
pattern: /([?&](?:access_)?(?:token|password|secret|authorization)=)[^\s&#;,)]+/giu,
|
|
58
|
+
replacement: "$1[redacted]",
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
pattern: /(^|[\s(["'`])(?:prompt|system prompt|user prompt|assistant response|thinking|raw content)\s*:[^.;\n]*/giu,
|
|
62
|
+
replacement: "$1[redacted-content]",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
pattern: /(^|[\s(["'`])(?:sudo|rm|mv|cp|curl|wget|npm|pnpm|yarn|node|python3?|bash|sh|git)\s+[^.;\n)]*/giu,
|
|
66
|
+
replacement: "$1[redacted-command]",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
pattern: /(^|[\s(["'`])(?:~|\.{2}\/|\.\/|\/Users\/|\/home\/|\/tmp\/|[A-Za-z]:\\|\\\\)[^\s)"'`,;]*/gu,
|
|
70
|
+
replacement: "$1[redacted-path]",
|
|
71
|
+
},
|
|
72
|
+
] as const satisfies readonly DiagnosticReplacement[];
|
|
73
|
+
|
|
74
|
+
export function assertNoSensitiveQueryInput(value: string, surface: string): void {
|
|
75
|
+
if (!isSensitiveQueryInput(value)) return;
|
|
76
|
+
|
|
77
|
+
throw new Error(`Unsafe ${surface}: ${SENSITIVE_QUERY_INPUT_DETAILS}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function isSensitiveQueryInput(value: string): boolean {
|
|
81
|
+
return sensitiveQueryInputPatterns.some(pattern => pattern.test(value));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function normalizeObservMeSessionId(value: string | undefined, options: ObservMeSessionIdOptions): string {
|
|
85
|
+
const sessionId = normalizeOptionalString(value);
|
|
86
|
+
|
|
87
|
+
if (!sessionId) throw new Error(options.emptyMessage);
|
|
88
|
+
if (isUnsafeObservMeSessionId(sessionId)) throw new Error(`Unsafe ObservMe session id: ${UNSAFE_OBSERVME_SESSION_ID_DETAILS}`);
|
|
89
|
+
return sessionId;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function isUnsafeObservMeSessionId(value: string): boolean {
|
|
93
|
+
return !safeObservMeSessionIdPattern.test(value) || isSensitiveQueryInput(value);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function hasUnresolvedEnvironmentPlaceholder(value: string): boolean {
|
|
97
|
+
return unresolvedEnvironmentPlaceholderPattern.test(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function readDiagnosticMessage(error: unknown): string {
|
|
101
|
+
if (error instanceof Error) return error.message;
|
|
102
|
+
if (typeof error === "string") return error;
|
|
103
|
+
return String(error);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function sanitizeUiDiagnosticText(message: string): string {
|
|
107
|
+
const normalized = normalizeDiagnosticWhitespace(message);
|
|
108
|
+
const redactedUrlCredentials = redactUrlCredentials(normalized);
|
|
109
|
+
const redacted = diagnosticReplacements.reduce(applyDiagnosticReplacement, redactedUrlCredentials);
|
|
110
|
+
return truncateDiagnostic(redacted || "unknown error");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function applyDiagnosticReplacement(message: string, replacement: DiagnosticReplacement): string {
|
|
114
|
+
return message.replace(replacement.pattern, replacement.replacement);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function redactUrlCredentials(message: string): string {
|
|
118
|
+
let redacted = "";
|
|
119
|
+
let cursor = 0;
|
|
120
|
+
let searchFrom = 0;
|
|
121
|
+
|
|
122
|
+
while (searchFrom < message.length) {
|
|
123
|
+
const markerIndex = message.indexOf("://", searchFrom);
|
|
124
|
+
if (markerIndex === -1) break;
|
|
125
|
+
|
|
126
|
+
const schemeStart = findUrlSchemeStart(message, markerIndex);
|
|
127
|
+
if (!isValidUrlScheme(message, schemeStart, markerIndex)) {
|
|
128
|
+
searchFrom = markerIndex + 3;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const credentialsStart = markerIndex + 3;
|
|
133
|
+
const authorityEnd = findUrlAuthorityEnd(message, credentialsStart);
|
|
134
|
+
const atIndex = message.indexOf("@", credentialsStart);
|
|
135
|
+
if (atIndex === -1 || atIndex >= authorityEnd) {
|
|
136
|
+
searchFrom = authorityEnd + 1;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const colonIndex = message.indexOf(":", credentialsStart);
|
|
141
|
+
if (colonIndex === -1 || colonIndex >= atIndex || colonIndex === credentialsStart || colonIndex + 1 === atIndex) {
|
|
142
|
+
searchFrom = atIndex + 1;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
redacted += message.slice(cursor, credentialsStart);
|
|
147
|
+
redacted += "[redacted]@";
|
|
148
|
+
cursor = atIndex + 1;
|
|
149
|
+
searchFrom = atIndex + 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return redacted + message.slice(cursor);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function findUrlSchemeStart(message: string, schemeEnd: number): number {
|
|
156
|
+
let start = schemeEnd - 1;
|
|
157
|
+
while (start > 0 && isUrlSchemeCharacter(message[start - 1])) start -= 1;
|
|
158
|
+
return start;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isValidUrlScheme(message: string, schemeStart: number, schemeEnd: number): boolean {
|
|
162
|
+
if (schemeStart >= schemeEnd) return false;
|
|
163
|
+
if (!isAsciiLetter(message[schemeStart])) return false;
|
|
164
|
+
|
|
165
|
+
for (let index = schemeStart + 1; index < schemeEnd; index += 1) {
|
|
166
|
+
if (!isUrlSchemeCharacter(message[index])) return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function findUrlAuthorityEnd(message: string, start: number): number {
|
|
173
|
+
let index = start;
|
|
174
|
+
while (index < message.length && !isUrlAuthorityTerminator(message[index])) index += 1;
|
|
175
|
+
return index;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isUrlAuthorityTerminator(value: string): boolean {
|
|
179
|
+
return value === "/" || value === "?" || value === "#" || value === " ";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isUrlSchemeCharacter(value: string): boolean {
|
|
183
|
+
return isAsciiLetter(value) || isAsciiDigit(value) || value === "+" || value === "." || value === "-";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isAsciiLetter(value: string): boolean {
|
|
187
|
+
const code = value.codePointAt(0);
|
|
188
|
+
return code !== undefined && ((code >= 65 && code <= 90) || (code >= 97 && code <= 122));
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function isAsciiDigit(value: string): boolean {
|
|
192
|
+
const code = value.codePointAt(0);
|
|
193
|
+
return code !== undefined && code >= 48 && code <= 57;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function normalizeOptionalString(value: string | undefined): string | undefined {
|
|
197
|
+
const trimmed = value?.trim();
|
|
198
|
+
return trimmed || undefined;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function normalizeDiagnosticWhitespace(message: string): string {
|
|
202
|
+
return message.replace(/\s+/gu, " ").trim();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function truncateDiagnostic(message: string): string {
|
|
206
|
+
if (message.length <= maximumDiagnosticLength) return message;
|
|
207
|
+
return `${message.slice(0, maximumDiagnosticLength - 1)}…`;
|
|
208
|
+
}
|