@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.
- package/CHANGELOG.md +35 -0
- package/README.md +24 -8
- package/docs/agent-subagent-observability-requirements.md +4 -3
- package/docs/compatibility-matrix.md +11 -2
- package/docs/configuration.md +2 -2
- package/docs/extension-integration.md +20 -13
- package/docs/reference/03-pi-event-and-session-model.md +6 -6
- package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
- package/docs/reference/06-security-privacy-redaction.md +13 -6
- package/docs/reference/08-query-grafana-integration.md +30 -7
- package/docs/reference/11-deployment-runbooks.md +21 -0
- package/docs/reference/12-configuration-reference.md +30 -4
- package/examples/integrations/subagent-runner.ts +8 -5
- package/examples/observme.yaml +2 -2
- package/package.json +12 -4
- package/src/commands/obs-agents.ts +17 -12
- package/src/commands/obs-backfill.ts +2 -2
- package/src/commands/obs-command-support.ts +2 -1
- package/src/commands/obs-cost.ts +15 -7
- package/src/commands/obs-health.ts +22 -2
- package/src/commands/obs-loki-summary.ts +4 -8
- package/src/commands/obs-session.ts +35 -28
- package/src/commands/obs-status.ts +56 -13
- package/src/commands/obs-tools.ts +19 -8
- package/src/commands/obs-trace.ts +9 -0
- package/src/commands/obs.ts +6 -1
- package/src/config/bootstrap-project-config.ts +49 -32
- package/src/config/defaults.ts +0 -2
- package/src/config/load-config.ts +270 -92
- package/src/config/project-paths.ts +318 -6
- package/src/config/query-limits.ts +10 -0
- package/src/config/schema.ts +9 -8
- package/src/config/transport-security.ts +107 -0
- package/src/config/validate.ts +68 -11
- package/src/extension.ts +2 -12
- package/src/integration.ts +6 -2
- package/src/otel/logs.ts +24 -13
- package/src/otel/metrics.ts +24 -13
- package/src/otel/otlp-endpoint.ts +41 -2
- package/src/otel/otlp-http-options.ts +11 -0
- package/src/otel/sdk.ts +155 -9
- package/src/otel/shutdown.ts +39 -11
- package/src/otel/traces.ts +34 -16
- package/src/pi/agent-tree-tracker.ts +14 -1
- package/src/pi/compatibility.ts +121 -0
- package/src/pi/event-handlers/agent-turn.ts +16 -9
- package/src/pi/event-handlers/lifecycle.ts +207 -75
- package/src/pi/event-handlers/llm.ts +17 -9
- package/src/pi/event-handlers/session-events.ts +53 -19
- package/src/pi/event-handlers/tool-bash.ts +21 -27
- package/src/pi/handler-internals.ts +16 -26
- package/src/pi/handler-runtime.ts +138 -51
- package/src/pi/handler-types.ts +30 -15
- package/src/pi/handlers.ts +12 -1
- package/src/pi/integration-api.ts +27 -15
- package/src/pi/session-correlation.ts +167 -0
- package/src/pi/subagent-spawn.ts +164 -31
- package/src/privacy/redact.ts +574 -68
- package/src/privacy/secret-patterns.ts +6 -1
- package/src/query/grafana-readiness.ts +18 -2
- package/src/query/grafana-transport.ts +150 -27
- package/src/query/grafana-url.ts +36 -0
- package/src/query/grafana.ts +4 -136
- package/src/query/loki.ts +2 -4
- package/src/query/prometheus.ts +8 -10
- package/src/query/tempo.ts +2 -4
- package/src/query/trace-link.ts +186 -0
- package/src/safety/display-bounds.ts +84 -0
- package/src/semconv/metrics.ts +1 -0
- 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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
const body =
|
|
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
|
|
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
|
+
}
|
package/src/query/grafana.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
330
|
-
if (!Number.isFinite(maxLogs) || maxLogs < minimumMaxLogs) return minimumMaxLogs;
|
|
331
|
-
return Math.trunc(maxLogs);
|
|
329
|
+
return normalizeQueryResultCount(config.query.maxLogs);
|
|
332
330
|
}
|
package/src/query/prometheus.ts
CHANGED
|
@@ -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
|
|
314
|
-
|
|
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
|
|
359
|
+
return normalizeQueryResultCount(limit);
|
|
358
360
|
}
|
|
359
361
|
|
|
360
362
|
function resolveMaxMetricSeries(config: ObservMeConfig): number {
|
|
361
|
-
|
|
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
|
-
|
|
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 {
|
package/src/query/tempo.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";
|
|
@@ -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
|
-
|
|
330
|
-
if (!Number.isFinite(maxTraces) || maxTraces < minimumMaxTraces) return minimumMaxTraces;
|
|
331
|
-
return Math.trunc(maxTraces);
|
|
329
|
+
return normalizeQueryResultCount(config.query.maxTraces);
|
|
332
330
|
}
|