@senad-d/observme 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +24 -8
  3. package/docs/agent-subagent-observability-requirements.md +4 -3
  4. package/docs/compatibility-matrix.md +11 -2
  5. package/docs/configuration.md +2 -2
  6. package/docs/extension-integration.md +20 -13
  7. package/docs/reference/03-pi-event-and-session-model.md +6 -6
  8. package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
  9. package/docs/reference/06-security-privacy-redaction.md +13 -6
  10. package/docs/reference/08-query-grafana-integration.md +30 -7
  11. package/docs/reference/11-deployment-runbooks.md +21 -0
  12. package/docs/reference/12-configuration-reference.md +30 -4
  13. package/examples/integrations/subagent-runner.ts +8 -5
  14. package/examples/observme.yaml +2 -2
  15. package/package.json +12 -4
  16. package/src/commands/obs-agents.ts +17 -12
  17. package/src/commands/obs-backfill.ts +2 -2
  18. package/src/commands/obs-command-support.ts +2 -1
  19. package/src/commands/obs-cost.ts +15 -7
  20. package/src/commands/obs-health.ts +22 -2
  21. package/src/commands/obs-loki-summary.ts +4 -8
  22. package/src/commands/obs-session.ts +35 -28
  23. package/src/commands/obs-status.ts +56 -13
  24. package/src/commands/obs-tools.ts +19 -8
  25. package/src/commands/obs-trace.ts +9 -0
  26. package/src/commands/obs.ts +6 -1
  27. package/src/config/bootstrap-project-config.ts +49 -32
  28. package/src/config/defaults.ts +0 -2
  29. package/src/config/load-config.ts +270 -92
  30. package/src/config/project-paths.ts +318 -6
  31. package/src/config/query-limits.ts +10 -0
  32. package/src/config/schema.ts +9 -8
  33. package/src/config/transport-security.ts +107 -0
  34. package/src/config/validate.ts +68 -11
  35. package/src/extension.ts +2 -12
  36. package/src/integration.ts +6 -2
  37. package/src/otel/logs.ts +24 -13
  38. package/src/otel/metrics.ts +24 -13
  39. package/src/otel/otlp-endpoint.ts +41 -2
  40. package/src/otel/otlp-http-options.ts +11 -0
  41. package/src/otel/sdk.ts +155 -9
  42. package/src/otel/shutdown.ts +39 -11
  43. package/src/otel/traces.ts +34 -16
  44. package/src/pi/agent-tree-tracker.ts +14 -1
  45. package/src/pi/compatibility.ts +121 -0
  46. package/src/pi/event-handlers/agent-turn.ts +16 -9
  47. package/src/pi/event-handlers/lifecycle.ts +207 -75
  48. package/src/pi/event-handlers/llm.ts +17 -9
  49. package/src/pi/event-handlers/session-events.ts +53 -19
  50. package/src/pi/event-handlers/tool-bash.ts +21 -27
  51. package/src/pi/handler-internals.ts +16 -26
  52. package/src/pi/handler-runtime.ts +138 -51
  53. package/src/pi/handler-types.ts +30 -15
  54. package/src/pi/handlers.ts +12 -1
  55. package/src/pi/integration-api.ts +27 -15
  56. package/src/pi/session-correlation.ts +167 -0
  57. package/src/pi/subagent-spawn.ts +164 -31
  58. package/src/privacy/redact.ts +574 -68
  59. package/src/privacy/secret-patterns.ts +6 -1
  60. package/src/query/grafana-readiness.ts +18 -2
  61. package/src/query/grafana-transport.ts +150 -27
  62. package/src/query/grafana-url.ts +36 -0
  63. package/src/query/grafana.ts +4 -136
  64. package/src/query/loki.ts +2 -4
  65. package/src/query/prometheus.ts +8 -10
  66. package/src/query/tempo.ts +2 -4
  67. package/src/query/trace-link.ts +186 -0
  68. package/src/safety/display-bounds.ts +84 -0
  69. package/src/semconv/metrics.ts +1 -0
  70. package/src/semconv/values.ts +2 -0
@@ -49,7 +49,12 @@ export const GITHUB_TOKEN_PATTERN = /(gh[pousr]_\w{36,}|github_pat_\w{22,255})/g
49
49
  export const OPENAI_LIKE_KEY_PATTERN = /sk-[A-Za-z0-9_-]{20,}/gu;
50
50
  export const ANTHROPIC_LIKE_KEY_PATTERN = /sk-ant-[A-Za-z0-9_-]{20,}/gu;
51
51
  export const SLACK_TOKEN_PATTERN = /xox[baprs]-[A-Za-z0-9-]{10,}/gu;
52
- export const PRIVATE_KEY_BLOCK_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/gu;
52
+ export const MAX_PRIVATE_KEY_BODY_CHARS = 1_000_000;
53
+ const PRIVATE_KEY_LABEL_PATTERN = "([A-Z ]{0,64}PRIVATE KEY)";
54
+ export const PRIVATE_KEY_BLOCK_PATTERN = new RegExp(
55
+ String.raw`-----BEGIN ${PRIVATE_KEY_LABEL_PATTERN}-----[\s\S]{0,${MAX_PRIVATE_KEY_BODY_CHARS}}?(?:-----END \1-----|$)`,
56
+ "gu",
57
+ );
53
58
  export const PASSWORD_ASSIGNMENT_PATTERN = /(password|passwd|pwd)\s*[:=]\s*[^\s]+/giu;
54
59
  export const API_KEY_ASSIGNMENT_PATTERN = /(api[_-]?key|token|secret|client[_-]?secret)\s*[:=]\s*[^\s]+/giu;
55
60
  export const URL_CREDENTIALS_PATTERN = /[a-z][a-z0-9+.-]{0,63}:\/\/[^\s:/?#]{1,1024}:[^\s@/]{1,1024}@/gu;
@@ -1,5 +1,10 @@
1
1
  import type { GrafanaDatasourceUidsConfig, ObservMeConfig } from "../config/schema.ts";
2
2
  import { hasUnresolvedEnvironmentPlaceholder, normalizeConfiguredGrafanaSecret } from "./grafana-transport.ts";
3
+ import type { GrafanaUrlSecurityFailureClass } from "./grafana-url.ts";
4
+ import {
5
+ classifyGrafanaUrlSecurityFailure,
6
+ formatGrafanaUrlSecurityFailure,
7
+ } from "./grafana-url.ts";
3
8
 
4
9
  export type GrafanaQueryDatasourceKey = keyof GrafanaDatasourceUidsConfig;
5
10
  export type GrafanaQueryReadinessStatus = "ready" | "disabled" | "not_ready";
@@ -66,12 +71,23 @@ function validateGrafanaUrl(url: string): GrafanaQueryReadinessIssue[] {
66
71
  function validateGrafanaUrlProtocol(url: string): GrafanaQueryReadinessIssue[] {
67
72
  try {
68
73
  const parsed = new URL(url);
69
- if (parsed.protocol === "http:" || parsed.protocol === "https:") return [];
74
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return [createInvalidGrafanaUrlIssue()];
75
+
76
+ const securityFailure = classifyGrafanaUrlSecurityFailure(parsed);
77
+ return securityFailure ? [createEmbeddedGrafanaCredentialsIssue(securityFailure)] : [];
70
78
  } catch (error) {
71
79
  return [createInvalidGrafanaUrlIssue(readUrlParseFailureKind(error))];
72
80
  }
81
+ }
73
82
 
74
- return [createInvalidGrafanaUrlIssue()];
83
+ function createEmbeddedGrafanaCredentialsIssue(
84
+ failureClass: GrafanaUrlSecurityFailureClass,
85
+ ): GrafanaQueryReadinessIssue {
86
+ return createGrafanaReadinessIssue(
87
+ "embedded_grafana_url_credentials",
88
+ "query.grafana.url",
89
+ formatGrafanaUrlSecurityFailure(failureClass),
90
+ );
75
91
  }
76
92
 
77
93
  function createInvalidGrafanaUrlIssue(failureKind?: string): GrafanaQueryReadinessIssue {
@@ -2,9 +2,11 @@ import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, RequestOption
2
2
  import { request as requestHttp } from "node:http";
3
3
  import type { RequestOptions as HttpsRequestOptions } from "node:https";
4
4
  import { request as requestHttps } from "node:https";
5
+ import { Readable } from "node:stream";
5
6
  import type { ObservMeConfig } from "../config/schema.ts";
6
7
  import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
7
8
  import { hasUnresolvedEnvironmentPlaceholder } from "../safety/sensitive-input.ts";
9
+ import { assertCredentialFreeGrafanaUrl } from "./grafana-url.ts";
8
10
 
9
11
  export type GrafanaFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
10
12
  export type GrafanaAuthMode = "bearer" | "basic" | "none";
@@ -48,6 +50,33 @@ const tlsErrorCodes = new Set<string>([
48
50
  const dnsErrorCodes = new Set<string>(["ENOTFOUND", "EAI_AGAIN"]);
49
51
  const connectionTimeoutErrorCodes = new Set<string>(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT"]);
50
52
 
53
+ type GrafanaResponseChunk = Uint8Array<ArrayBuffer>;
54
+
55
+ class BufferedGrafanaResponseBodySource implements UnderlyingSource<GrafanaResponseChunk> {
56
+ #chunks: (GrafanaResponseChunk | undefined)[];
57
+ #index = 0;
58
+
59
+ constructor(chunks: GrafanaResponseChunk[]) {
60
+ this.#chunks = chunks;
61
+ }
62
+
63
+ pull(controller: ReadableStreamController<GrafanaResponseChunk>): void {
64
+ const chunk = this.#chunks[this.#index];
65
+ if (chunk === undefined) {
66
+ controller.close();
67
+ return;
68
+ }
69
+
70
+ this.#chunks[this.#index] = undefined;
71
+ this.#index += 1;
72
+ controller.enqueue(chunk);
73
+ }
74
+
75
+ cancel(): void {
76
+ this.#chunks = [];
77
+ }
78
+ }
79
+
51
80
  export class GrafanaTransportClient {
52
81
  readonly #config: ObservMeConfig;
53
82
  readonly #fetcher: GrafanaFetch;
@@ -76,6 +105,7 @@ export class GrafanaTransportClient {
76
105
  }
77
106
 
78
107
  async fetch(input: string | URL, options: GrafanaTransportFetchOptions = {}): Promise<Response> {
108
+ assertCredentialFreeGrafanaUrl(input);
79
109
  return fetchGrafanaRequest(
80
110
  this.#fetcher,
81
111
  input,
@@ -99,6 +129,7 @@ export function createGrafanaTransport(
99
129
 
100
130
  export function buildGrafanaApiUrl(baseUrl: string, apiPath: string): URL {
101
131
  const url = new URL(baseUrl.trim());
132
+ assertCredentialFreeGrafanaUrl(url);
102
133
  const basePath = removeTrailingSlashes(url.pathname);
103
134
  const path = removeLeadingSlashes(apiPath);
104
135
  url.pathname = `${basePath}/${path}`;
@@ -204,7 +235,8 @@ async function fetchGrafanaRequest(
204
235
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
205
236
 
206
237
  try {
207
- return await fetcher(input, { ...init, signal: controller.signal });
238
+ const response = await fetcher(input, { ...init, signal: controller.signal });
239
+ return await readBoundedGrafanaResponse(response, MAX_GRAFANA_RESPONSE_BODY_BYTES, controller.signal);
208
240
  } catch (error) {
209
241
  throw normalizeGrafanaTransportError(error, timeoutMessage);
210
242
  } finally {
@@ -212,6 +244,113 @@ async function fetchGrafanaRequest(
212
244
  }
213
245
  }
214
246
 
247
+ async function readBoundedGrafanaResponse(
248
+ response: Response,
249
+ maxBytes: number,
250
+ signal: AbortSignal,
251
+ ): Promise<Response> {
252
+ const declaredBytes = readDeclaredResponseBodyBytes(response.headers);
253
+ if (declaredBytes !== undefined && declaredBytes > maxBytes) {
254
+ const error = createGrafanaResponseBodyLimitError(maxBytes);
255
+ cancelGrafanaResponseBody(response.body, error);
256
+ throw error;
257
+ }
258
+
259
+ if (response.body === null) return response;
260
+
261
+ const chunks = await readBoundedResponseBody(response.body, maxBytes, signal);
262
+ return createBufferedGrafanaResponse(response, chunks);
263
+ }
264
+
265
+ async function readBoundedResponseBody(
266
+ body: ReadableStream<Uint8Array>,
267
+ maxBytes: number,
268
+ signal: AbortSignal,
269
+ ): Promise<GrafanaResponseChunk[]> {
270
+ const reader = body.getReader();
271
+ const chunks: GrafanaResponseChunk[] = [];
272
+ let totalBytes = 0;
273
+
274
+ try {
275
+ while (true) {
276
+ const result = await readGrafanaResponseChunk(reader, signal);
277
+ if (result.done) return chunks;
278
+
279
+ totalBytes += result.value.byteLength;
280
+ if (totalBytes > maxBytes) throw createGrafanaResponseBodyLimitError(maxBytes);
281
+ chunks.push(normalizeGrafanaResponseChunk(result.value));
282
+ }
283
+ } catch (error) {
284
+ cancelGrafanaResponseReader(reader, error);
285
+ throw error;
286
+ } finally {
287
+ reader.releaseLock();
288
+ }
289
+ }
290
+
291
+ function readGrafanaResponseChunk(
292
+ reader: ReadableStreamDefaultReader<Uint8Array>,
293
+ signal: AbortSignal,
294
+ ): Promise<ReadableStreamReadResult<Uint8Array>> {
295
+ if (signal.aborted) return Promise.reject(createAbortError());
296
+
297
+ return new Promise((resolve, reject) => {
298
+ const abortRead = () => {
299
+ const error = createAbortError();
300
+ cancelGrafanaResponseReader(reader, error);
301
+ reject(error);
302
+ };
303
+ const removeAbortListener = () => signal.removeEventListener("abort", abortRead);
304
+
305
+ signal.addEventListener("abort", abortRead, { once: true });
306
+ reader.read().then(
307
+ result => {
308
+ removeAbortListener();
309
+ resolve(result);
310
+ },
311
+ error => {
312
+ removeAbortListener();
313
+ reject(error);
314
+ },
315
+ );
316
+ });
317
+ }
318
+
319
+ function normalizeGrafanaResponseChunk(chunk: Uint8Array): GrafanaResponseChunk {
320
+ if (chunk.buffer instanceof ArrayBuffer) return chunk as GrafanaResponseChunk;
321
+ return new Uint8Array(chunk);
322
+ }
323
+
324
+ function readDeclaredResponseBodyBytes(headers: Headers): number | undefined {
325
+ const value = headers.get("content-length")?.trim();
326
+ if (!value || !/^\d+$/u.test(value)) return undefined;
327
+
328
+ const bytes = Number(value);
329
+ return Number.isSafeInteger(bytes) ? bytes : Number.POSITIVE_INFINITY;
330
+ }
331
+
332
+ function createBufferedGrafanaResponse(response: Response, chunks: GrafanaResponseChunk[]): Response {
333
+ const body = new ReadableStream<GrafanaResponseChunk>(new BufferedGrafanaResponseBodySource(chunks));
334
+ return new Response(body, {
335
+ status: response.status,
336
+ statusText: response.statusText,
337
+ headers: response.headers,
338
+ });
339
+ }
340
+
341
+ function cancelGrafanaResponseBody(body: ReadableStream<Uint8Array> | null, reason: unknown): void {
342
+ if (body === null) return;
343
+ void body.cancel(reason).catch(ignoreCancellationFailure);
344
+ }
345
+
346
+ function cancelGrafanaResponseReader(reader: ReadableStreamDefaultReader<Uint8Array>, reason: unknown): void {
347
+ void reader.cancel(reason).catch(ignoreCancellationFailure);
348
+ }
349
+
350
+ function ignoreCancellationFailure(): void {
351
+ // Stream cancellation is best-effort after the original transport failure is already known.
352
+ }
353
+
215
354
  function createGrafanaRequestInit(config: ObservMeConfig, options: GrafanaTransportFetchOptions): RequestInit {
216
355
  return {
217
356
  method: options.method ?? "GET",
@@ -238,8 +377,9 @@ export async function fetchGrafanaWithNode(
238
377
  ): Promise<Response> {
239
378
  const url = new URL(input);
240
379
  assertSupportedGrafanaUrl(url);
380
+ assertCredentialFreeGrafanaUrl(url);
241
381
 
242
- const requestOptions = createNodeRequestOptions(config, init);
382
+ const requestOptions = createGrafanaNodeRequestOptions(config, init);
243
383
  const body = normalizeRequestBody(init?.body);
244
384
  return requestNodeUrl(url, requestOptions, body, init?.signal);
245
385
  }
@@ -288,7 +428,10 @@ function describeMissingGrafanaAuth(config: ObservMeConfig): string {
288
428
  return "Grafana authentication is not configured. Configure query.grafana.token or query.grafana.username/password.";
289
429
  }
290
430
 
291
- function createNodeRequestOptions(config: ObservMeConfig, init: RequestInit | undefined): HttpRequestOptions | HttpsRequestOptions {
431
+ export function createGrafanaNodeRequestOptions(
432
+ config: ObservMeConfig,
433
+ init?: RequestInit,
434
+ ): HttpRequestOptions | HttpsRequestOptions {
292
435
  const options: HttpRequestOptions | HttpsRequestOptions = {
293
436
  method: init?.method ?? "GET",
294
437
  headers: normalizeRequestHeaders(init?.headers),
@@ -345,7 +488,7 @@ async function requestNodeUrl(
345
488
  return new Promise((resolve, reject) => {
346
489
  const client = url.protocol === "https:" ? requestHttps : requestHttp;
347
490
  const request = client(url, options, response => {
348
- readNodeResponse(response).then(resolve, reject);
491
+ resolve(createNodeResponse(response));
349
492
  });
350
493
  const removeAbortListener = attachAbortSignal(request, signal);
351
494
 
@@ -372,31 +515,11 @@ function attachAbortSignal(request: ClientRequest, signal: AbortSignal | null |
372
515
  return () => signal.removeEventListener("abort", abortRequest);
373
516
  }
374
517
 
375
- async function readNodeResponse(response: IncomingMessage): Promise<Response> {
376
- const body = await readNodeResponseBody(response, MAX_GRAFANA_RESPONSE_BODY_BYTES);
518
+ function createNodeResponse(response: IncomingMessage): Response {
519
+ const body = Readable.toWeb(response) as ReadableStream<Uint8Array>;
377
520
  const status = normalizeResponseStatus(response.statusCode);
378
521
  const statusText = response.statusMessage ?? "";
379
- return new Response(body.toString("utf8"), { status, statusText, headers: normalizeResponseHeaders(response.headers) });
380
- }
381
-
382
- async function readNodeResponseBody(response: IncomingMessage, maxBytes: number): Promise<Buffer> {
383
- const chunks: Buffer[] = [];
384
- let totalBytes = 0;
385
-
386
- for await (const chunk of response) {
387
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
388
- totalBytes += buffer.byteLength;
389
- if (totalBytes > maxBytes) throw destroyOversizedNodeResponse(response, maxBytes);
390
- chunks.push(buffer);
391
- }
392
-
393
- return Buffer.concat(chunks, totalBytes);
394
- }
395
-
396
- function destroyOversizedNodeResponse(response: IncomingMessage, maxBytes: number): GrafanaResponseBodyLimitError {
397
- const error = createGrafanaResponseBodyLimitError(maxBytes);
398
- response.destroy(error);
399
- return error;
522
+ return new Response(body, { status, statusText, headers: normalizeResponseHeaders(response.headers) });
400
523
  }
401
524
 
402
525
  function createGrafanaResponseBodyLimitError(maxBytes: number): GrafanaResponseBodyLimitError {
@@ -0,0 +1,36 @@
1
+ export type GrafanaUrlSecurityFailureClass = "embedded_credentials";
2
+
3
+ const embeddedCredentialsFailureClass: GrafanaUrlSecurityFailureClass = "embedded_credentials";
4
+ const grafanaUrlSecurityFailureMessages = {
5
+ embedded_credentials:
6
+ "query.grafana.url is invalid (embedded_credentials). Configure authentication through query.grafana.token or query.grafana.username/password.",
7
+ } satisfies Readonly<Record<GrafanaUrlSecurityFailureClass, string>>;
8
+
9
+ export function classifyGrafanaUrlSecurityFailure(
10
+ value: string | URL,
11
+ ): GrafanaUrlSecurityFailureClass | undefined {
12
+ const url = parseGrafanaUrl(value);
13
+ if (!url || (!url.username && !url.password)) return undefined;
14
+ return embeddedCredentialsFailureClass;
15
+ }
16
+
17
+ export function formatGrafanaUrlSecurityFailure(failureClass: GrafanaUrlSecurityFailureClass): string {
18
+ return grafanaUrlSecurityFailureMessages[failureClass];
19
+ }
20
+
21
+ export function assertCredentialFreeGrafanaUrl(value: string | URL): void {
22
+ const failureClass = classifyGrafanaUrlSecurityFailure(value);
23
+ if (!failureClass) return;
24
+
25
+ throw new Error(formatGrafanaUrlSecurityFailure(failureClass));
26
+ }
27
+
28
+ function parseGrafanaUrl(value: string | URL): URL | undefined {
29
+ if (value instanceof URL) return value;
30
+
31
+ try {
32
+ return new URL(value);
33
+ } catch {
34
+ return undefined;
35
+ }
36
+ }
@@ -1,10 +1,12 @@
1
1
  import type { ObservMeConfig } from "../config/schema.ts";
2
2
  import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
3
- import { assertNoSensitiveQueryInput } from "../safety/sensitive-input.ts";
4
3
  import type { GrafanaTransportClient, GrafanaTransportOptions } from "./grafana-transport.ts";
5
4
  import { createGrafanaTransport } from "./grafana-transport.ts";
6
5
  import type { GrafanaQueryDatasourceKey } from "./grafana-readiness.ts";
7
6
  import { formatGrafanaQueryReadiness, getGrafanaQueryReadiness } from "./grafana-readiness.ts";
7
+ import { buildGrafanaTraceLink } from "./trace-link.ts";
8
+
9
+ export { buildGrafanaTraceLink as getGrafanaTraceLink } from "./trace-link.ts";
8
10
 
9
11
  export type { GrafanaFetch } from "./grafana-transport.ts";
10
12
  export type GrafanaHealthCheckKind = "service" | "datasource";
@@ -38,58 +40,11 @@ interface GrafanaDatasourceDefinition {
38
40
  readonly fallbackHealthPath?: string;
39
41
  }
40
42
 
41
- interface TraceTemplateReplacement {
42
- readonly pattern: RegExp;
43
- readonly key: TraceTemplateValueKey;
44
- }
45
-
46
- type TraceTemplateValueKey = "traceId" | "tempoDatasourceUid";
47
-
48
- type TraceTemplateValues = Record<TraceTemplateValueKey, string>;
49
-
50
- interface GrafanaExploreDatasourceRef {
51
- readonly type: "tempo";
52
- readonly uid: string;
53
- }
54
-
55
- interface GrafanaTraceQuery {
56
- readonly refId: "A";
57
- readonly datasource: GrafanaExploreDatasourceRef;
58
- readonly queryType: "traceId";
59
- readonly query: string;
60
- }
61
-
62
- interface GrafanaExplorePane {
63
- readonly datasource: string;
64
- readonly queries: readonly GrafanaTraceQuery[];
65
- readonly range: {
66
- readonly from: "now-1h";
67
- readonly to: "now";
68
- };
69
- }
70
-
71
- type GrafanaExplorePanes = Record<string, GrafanaExplorePane>;
72
-
73
- const traceIdPattern = /^[a-f0-9]{32}$/iu;
74
- const zeroTraceIdPattern = /^0{32}$/u;
75
- const traceIdTemplatePattern = /\{\{\s*traceId\s*\}\}|\{traceId\}|\$\{traceId\}|%TRACE_ID%/u;
76
- const fallbackTraceTemplateMarkerPattern = /\.\.\./u;
77
43
  const datasourceDefinitions = [
78
44
  { label: "Tempo datasource", key: "tempo", fallbackHealthPath: "/ready" },
79
45
  { label: "Loki datasource", key: "loki", fallbackHealthPath: undefined },
80
46
  { label: "Metrics datasource", key: "prometheus", fallbackHealthPath: undefined },
81
47
  ] as const;
82
- const traceTemplateReplacements: readonly TraceTemplateReplacement[] = [
83
- { pattern: /\{\{\s*traceId\s*\}\}/gu, key: "traceId" },
84
- { pattern: /\$\{traceId\}/gu, key: "traceId" },
85
- { pattern: /\{traceId\}/gu, key: "traceId" },
86
- { pattern: /%TRACE_ID%/gu, key: "traceId" },
87
- { pattern: /\{\{\s*tempoDatasourceUid\s*\}\}/gu, key: "tempoDatasourceUid" },
88
- { pattern: /\$\{tempoDatasourceUid\}/gu, key: "tempoDatasourceUid" },
89
- { pattern: /\{tempoDatasourceUid\}/gu, key: "tempoDatasourceUid" },
90
- { pattern: /%TEMPO_DATASOURCE_UID%/gu, key: "tempoDatasourceUid" },
91
- ];
92
-
93
48
  export class GrafanaQueryClient {
94
49
  readonly #config: ObservMeConfig;
95
50
  readonly #transport: GrafanaTransportClient;
@@ -104,7 +59,7 @@ export class GrafanaQueryClient {
104
59
  }
105
60
 
106
61
  getTraceLink(traceId: string): string {
107
- return getGrafanaTraceLink(this.#config, traceId);
62
+ return buildGrafanaTraceLink(this.#config, traceId);
108
63
  }
109
64
  }
110
65
 
@@ -134,16 +89,6 @@ async function getGrafanaHealthWithTransport(
134
89
  return { timeoutMs: transport.timeoutMs, checks };
135
90
  }
136
91
 
137
- export function getGrafanaTraceLink(config: ObservMeConfig, traceId: string): string {
138
- const normalizedTraceId = normalizeTraceId(traceId);
139
- const template = config.query.links.traceUrlTemplate.trim();
140
-
141
- if (hasTraceIdTemplatePlaceholder(template)) return renderTraceUrlTemplate(template, config, normalizedTraceId);
142
- if (isFallbackTraceTemplate(template)) return buildDefaultGrafanaTraceLink(config, normalizedTraceId);
143
-
144
- throw new Error("Grafana traceUrlTemplate must include a traceId placeholder or use the default Grafana link fallback.");
145
- }
146
-
147
92
  async function checkGrafanaReachability(
148
93
  config: ObservMeConfig,
149
94
  transport: GrafanaTransportClient,
@@ -285,83 +230,6 @@ function skippedHealthResult(label: string, kind: GrafanaHealthCheckKind, detail
285
230
  return { label, kind, status: "skipped", detail };
286
231
  }
287
232
 
288
- function buildDefaultGrafanaTraceLink(config: ObservMeConfig, traceId: string): string {
289
- const baseUrl = config.query.grafana.url.trim();
290
- if (!baseUrl) throw new Error("Grafana URL is not configured for trace-link construction.");
291
-
292
- const url = new URL(baseUrl);
293
- const basePath = removeTrailingSlashes(url.pathname);
294
- url.pathname = `${basePath}/explore`;
295
- url.search = "";
296
- url.hash = "";
297
- url.searchParams.set("schemaVersion", "1");
298
- url.searchParams.set("panes", JSON.stringify(createDefaultExplorePanes(config, traceId)));
299
- return url.toString();
300
- }
301
-
302
- function removeTrailingSlashes(value: string): string {
303
- let end = value.length;
304
- while (end > 0 && value[end - 1] === "/") end -= 1;
305
- return value.slice(0, end);
306
- }
307
-
308
- function createDefaultExplorePanes(config: ObservMeConfig, traceId: string): GrafanaExplorePanes {
309
- const tempoUid = config.query.grafana.datasourceUids.tempo;
310
- return {
311
- observmeTrace: {
312
- datasource: tempoUid,
313
- queries: [
314
- {
315
- refId: "A",
316
- datasource: { type: "tempo", uid: tempoUid },
317
- queryType: "traceId",
318
- query: traceId,
319
- },
320
- ],
321
- range: { from: "now-1h", to: "now" },
322
- },
323
- };
324
- }
325
-
326
- function renderTraceUrlTemplate(template: string, config: ObservMeConfig, traceId: string): string {
327
- const values = createTraceTemplateValues(config, traceId);
328
- let rendered = template;
329
-
330
- for (const replacement of traceTemplateReplacements) {
331
- rendered = rendered.replace(replacement.pattern, values[replacement.key]);
332
- }
333
-
334
- return rendered;
335
- }
336
-
337
- function createTraceTemplateValues(config: ObservMeConfig, traceId: string): TraceTemplateValues {
338
- return {
339
- traceId: encodeURIComponent(traceId),
340
- tempoDatasourceUid: encodeURIComponent(config.query.grafana.datasourceUids.tempo),
341
- };
342
- }
343
-
344
- function normalizeTraceId(traceId: string): string {
345
- const trimmed = traceId.trim();
346
- assertNoSensitiveQueryInput(trimmed, "Grafana traceId");
347
-
348
- if (!traceIdPattern.test(trimmed) || zeroTraceIdPattern.test(trimmed)) {
349
- throw new Error(
350
- "Unsafe Grafana traceId: expected a non-zero 32-character hexadecimal OpenTelemetry trace id; raw prompts, commands, paths, and environment values are not query inputs.",
351
- );
352
- }
353
-
354
- return trimmed.toLowerCase();
355
- }
356
-
357
- function hasTraceIdTemplatePlaceholder(template: string): boolean {
358
- return traceIdTemplatePattern.test(template);
359
- }
360
-
361
- function isFallbackTraceTemplate(template: string): boolean {
362
- return template === "" || fallbackTraceTemplateMarkerPattern.test(template);
363
- }
364
-
365
233
  function formatHealthFailure(error: unknown): string {
366
234
  return formatError(error);
367
235
  }
package/src/query/loki.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeQueryResultCount } from "../config/query-limits.ts";
1
2
  import type { ObservMeConfig } from "../config/schema.ts";
2
3
  import type { GrafanaFetch, GrafanaTransportClient } from "./grafana-transport.ts";
3
4
  import { createGrafanaTransport } from "./grafana-transport.ts";
@@ -27,7 +28,6 @@ interface NormalizedTimeRange {
27
28
  readonly to: Date;
28
29
  }
29
30
 
30
- const minimumMaxLogs = 1;
31
31
  const maxLogQlQueryLength = 4096;
32
32
  const safeLokiAttributeNamePattern = /^[A-Za-z_][A-Za-z0-9_.]*$/u;
33
33
  const lokiIdentifierStartPattern = /^[A-Za-z_]$/u;
@@ -326,7 +326,5 @@ function isRecord(value: unknown): value is Record<string, unknown> {
326
326
  }
327
327
 
328
328
  function resolveMaxLogs(config: ObservMeConfig): number {
329
- const maxLogs = config.query.maxLogs;
330
- if (!Number.isFinite(maxLogs) || maxLogs < minimumMaxLogs) return minimumMaxLogs;
331
- return Math.trunc(maxLogs);
329
+ return normalizeQueryResultCount(config.query.maxLogs);
332
330
  }
@@ -1,7 +1,9 @@
1
+ import { normalizeQueryResultCount } from "../config/query-limits.ts";
1
2
  import type { ObservMeConfig } from "../config/schema.ts";
2
3
  import { LOG_ATTRIBUTES } from "../semconv/attributes.ts";
3
4
  import type { GrafanaFetch, GrafanaTransportClient } from "./grafana-transport.ts";
4
5
  import { createGrafanaTransport } from "./grafana-transport.ts";
6
+ import { normalizeObsBackendLabel } from "../safety/display-bounds.ts";
5
7
  import { assertNoSensitiveQueryInput } from "../safety/sensitive-input.ts";
6
8
  import { assertGrafanaQueryReady } from "./grafana-readiness.ts";
7
9
 
@@ -77,7 +79,6 @@ const allForbiddenHighCardinalityPrometheusLabels = [
77
79
  ...forbiddenHighCardinalityPrometheusLabelAliases,
78
80
  ] as const;
79
81
  const minimumMaxMetricSeries = 1;
80
- const minimumMaxAgents = 1;
81
82
  const maxPromQlQueryLength = 4096;
82
83
 
83
84
  export class PrometheusQueryClient {
@@ -310,8 +311,9 @@ function readStringRecord(value: unknown): Record<string, string> {
310
311
  if (!isRecord(value)) return record;
311
312
 
312
313
  for (const [key, item] of Object.entries(value)) {
313
- const text = readStringOrNumber(item);
314
- if (text !== undefined) record[key] = text;
314
+ const normalizedKey = normalizeObsBackendLabel(key);
315
+ const normalizedValue = normalizeObsBackendLabel(readStringOrNumber(item));
316
+ if (normalizedKey && normalizedValue !== undefined) record[normalizedKey] = normalizedValue;
315
317
  }
316
318
 
317
319
  return record;
@@ -354,19 +356,15 @@ function normalizeExplicitResultLimit(limit: number): number {
354
356
  throw new Error("Prometheus result limit must be a positive finite number.");
355
357
  }
356
358
 
357
- return Math.trunc(limit);
359
+ return normalizeQueryResultCount(limit);
358
360
  }
359
361
 
360
362
  function resolveMaxMetricSeries(config: ObservMeConfig): number {
361
- const maxMetricSeries = config.query.maxMetricSeries;
362
- if (!Number.isFinite(maxMetricSeries) || maxMetricSeries < minimumMaxMetricSeries) return minimumMaxMetricSeries;
363
- return Math.trunc(maxMetricSeries);
363
+ return normalizeQueryResultCount(config.query.maxMetricSeries);
364
364
  }
365
365
 
366
366
  function resolveMaxAgents(config: ObservMeConfig): number {
367
- const maxAgents = config.query.maxAgents;
368
- if (!Number.isFinite(maxAgents) || maxAgents < minimumMaxAgents) return minimumMaxAgents;
369
- return Math.trunc(maxAgents);
367
+ return normalizeQueryResultCount(config.query.maxAgents);
370
368
  }
371
369
 
372
370
  function formatPrometheusTimestamp(date: Date): string {
@@ -1,3 +1,4 @@
1
+ import { normalizeQueryResultCount } from "../config/query-limits.ts";
1
2
  import type { ObservMeConfig } from "../config/schema.ts";
2
3
  import type { GrafanaFetch, GrafanaTransportClient } from "./grafana-transport.ts";
3
4
  import { createGrafanaTransport } from "./grafana-transport.ts";
@@ -45,7 +46,6 @@ interface NormalizedTimeRange {
45
46
  readonly to: Date;
46
47
  }
47
48
 
48
- const minimumMaxTraces = 1;
49
49
  const maxTempoSearchAttributes = 8;
50
50
  const maxTempoSearchAttributeValueLength = 256;
51
51
  const safeTempoAttributeKeyPattern = /^[A-Za-z_][A-Za-z0-9_.-]*$/u;
@@ -326,7 +326,5 @@ function isRecord(value: unknown): value is Record<string, unknown> {
326
326
  }
327
327
 
328
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);
329
+ return normalizeQueryResultCount(config.query.maxTraces);
332
330
  }