@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.
Files changed (111) hide show
  1. package/.env.example +57 -0
  2. package/CHANGELOG.md +52 -0
  3. package/LICENSE +21 -0
  4. package/ObservMe-Production-Docs/00-README.md +79 -0
  5. package/ObservMe-Production-Docs/01-requirements-and-scope.md +207 -0
  6. package/ObservMe-Production-Docs/02-reference-architecture.md +306 -0
  7. package/ObservMe-Production-Docs/03-pi-event-and-session-model.md +266 -0
  8. package/ObservMe-Production-Docs/04-telemetry-semantic-conventions.md +722 -0
  9. package/ObservMe-Production-Docs/05-otel-pipeline-and-collector.md +355 -0
  10. package/ObservMe-Production-Docs/06-security-privacy-redaction.md +294 -0
  11. package/ObservMe-Production-Docs/07-extension-implementation-blueprint.md +447 -0
  12. package/ObservMe-Production-Docs/08-query-grafana-integration.md +276 -0
  13. package/ObservMe-Production-Docs/09-dashboards-alerts-slos.md +640 -0
  14. package/ObservMe-Production-Docs/10-testing-release-operations.md +319 -0
  15. package/ObservMe-Production-Docs/11-deployment-runbooks.md +203 -0
  16. package/ObservMe-Production-Docs/12-configuration-reference.md +337 -0
  17. package/ObservMe-Production-Docs/13-source-notes.md +33 -0
  18. package/ObservMe-Production-Docs/pi-session-format.md +427 -0
  19. package/README.md +356 -0
  20. package/SECURITY.md +45 -0
  21. package/dashboards/observme-agent-node-graphs.json +250 -0
  22. package/dashboards/observme-agents.json +1880 -0
  23. package/dashboards/observme-alerts.yaml +113 -0
  24. package/dashboards/observme-branches-compactions.json +1042 -0
  25. package/dashboards/observme-cost.json +1254 -0
  26. package/dashboards/observme-errors.json +1659 -0
  27. package/dashboards/observme-export-health.json +1802 -0
  28. package/dashboards/observme-latency.json +1494 -0
  29. package/dashboards/observme-llm-conversations.json +730 -0
  30. package/dashboards/observme-logs-llm.json +644 -0
  31. package/dashboards/observme-models.json +933 -0
  32. package/dashboards/observme-overview.json +2129 -0
  33. package/dashboards/observme-slo-health.json +737 -0
  34. package/dashboards/observme-slos.yaml +56 -0
  35. package/dashboards/observme-tools.json +902 -0
  36. package/dashboards/observme-trace-journey.json +1793 -0
  37. package/docs/STRUCTURE.md +49 -0
  38. package/docs/agent-subagent-observability-requirements.md +997 -0
  39. package/docs/compatibility-matrix.md +39 -0
  40. package/docs/configuration-tui-design-standard.md +767 -0
  41. package/docs/configuration.md +38 -0
  42. package/docs/review-validation.md +115 -0
  43. package/docs/validation-flow.md +117 -0
  44. package/examples/collector.yaml +123 -0
  45. package/examples/observme.yaml +131 -0
  46. package/img/demo.gif +0 -0
  47. package/img/icon.svg +47 -0
  48. package/package.json +103 -0
  49. package/src/commands/obs-agents-runtime.ts +150 -0
  50. package/src/commands/obs-agents.ts +491 -0
  51. package/src/commands/obs-args.ts +63 -0
  52. package/src/commands/obs-backfill.ts +1334 -0
  53. package/src/commands/obs-command-support.ts +43 -0
  54. package/src/commands/obs-cost.ts +228 -0
  55. package/src/commands/obs-diagnostics.ts +22 -0
  56. package/src/commands/obs-errors.ts +156 -0
  57. package/src/commands/obs-health.ts +301 -0
  58. package/src/commands/obs-link.ts +90 -0
  59. package/src/commands/obs-logs.ts +194 -0
  60. package/src/commands/obs-loki-summary.ts +184 -0
  61. package/src/commands/obs-session.ts +259 -0
  62. package/src/commands/obs-status.ts +359 -0
  63. package/src/commands/obs-tools.ts +274 -0
  64. package/src/commands/obs-trace.ts +411 -0
  65. package/src/commands/obs.ts +211 -0
  66. package/src/config/bootstrap-project-config.ts +300 -0
  67. package/src/config/defaults.ts +143 -0
  68. package/src/config/load-config.ts +631 -0
  69. package/src/config/project-paths.ts +61 -0
  70. package/src/config/schema.ts +405 -0
  71. package/src/config/validate.ts +456 -0
  72. package/src/constants.ts +4 -0
  73. package/src/diagnostics/sanitize.ts +6 -0
  74. package/src/extension.ts +38 -0
  75. package/src/otel/logs.ts +160 -0
  76. package/src/otel/metrics.ts +165 -0
  77. package/src/otel/otlp-endpoint.ts +10 -0
  78. package/src/otel/sdk.ts +114 -0
  79. package/src/otel/shutdown.ts +102 -0
  80. package/src/otel/traces.ts +166 -0
  81. package/src/pi/agent-lineage.ts +378 -0
  82. package/src/pi/agent-tree-tracker.ts +258 -0
  83. package/src/pi/event-handlers/agent-turn.ts +155 -0
  84. package/src/pi/event-handlers/lifecycle.ts +642 -0
  85. package/src/pi/event-handlers/llm.ts +115 -0
  86. package/src/pi/event-handlers/session-events.ts +159 -0
  87. package/src/pi/event-handlers/tool-bash.ts +275 -0
  88. package/src/pi/handler-internals.ts +2154 -0
  89. package/src/pi/handler-runtime.ts +633 -0
  90. package/src/pi/handler-types.ts +261 -0
  91. package/src/pi/handlers.ts +75 -0
  92. package/src/pi/subagent-spawn.ts +975 -0
  93. package/src/pi/subagent-types.ts +29 -0
  94. package/src/privacy/content-capture.ts +104 -0
  95. package/src/privacy/hash.ts +93 -0
  96. package/src/privacy/redact.ts +619 -0
  97. package/src/privacy/secret-patterns.ts +185 -0
  98. package/src/privacy/truncate.ts +69 -0
  99. package/src/query/grafana-readiness.ts +164 -0
  100. package/src/query/grafana-transport.ts +481 -0
  101. package/src/query/grafana.ts +371 -0
  102. package/src/query/loki.ts +332 -0
  103. package/src/query/prometheus.ts +388 -0
  104. package/src/query/tempo.ts +332 -0
  105. package/src/safety/sensitive-input.ts +208 -0
  106. package/src/semconv/attributes.ts +279 -0
  107. package/src/semconv/metrics.ts +146 -0
  108. package/src/semconv/spans.ts +19 -0
  109. package/src/semconv/values.ts +13 -0
  110. package/src/util/bounded-map.ts +97 -0
  111. package/tsconfig.json +15 -0
@@ -0,0 +1,371 @@
1
+ import type { ObservMeConfig } from "../config/schema.ts";
2
+ import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
3
+ import { assertNoSensitiveQueryInput } from "../safety/sensitive-input.ts";
4
+ import type { GrafanaTransportClient, GrafanaTransportOptions } from "./grafana-transport.ts";
5
+ import { createGrafanaTransport } from "./grafana-transport.ts";
6
+ import type { GrafanaQueryDatasourceKey } from "./grafana-readiness.ts";
7
+ import { formatGrafanaQueryReadiness, getGrafanaQueryReadiness } from "./grafana-readiness.ts";
8
+
9
+ export type { GrafanaFetch } from "./grafana-transport.ts";
10
+ export type GrafanaHealthCheckKind = "service" | "datasource";
11
+ export type GrafanaHealthCheckStatus = "ok" | "failed" | "skipped";
12
+
13
+ export interface GrafanaHealthCheckResult {
14
+ readonly label: string;
15
+ readonly kind: GrafanaHealthCheckKind;
16
+ readonly status: GrafanaHealthCheckStatus;
17
+ readonly detail?: string;
18
+ }
19
+
20
+ export interface GrafanaHealthResult {
21
+ readonly timeoutMs: number;
22
+ readonly checks: readonly GrafanaHealthCheckResult[];
23
+ }
24
+
25
+ export type GrafanaQueryClientOptions = GrafanaTransportOptions;
26
+
27
+ interface GrafanaHealthTarget {
28
+ readonly label: string;
29
+ readonly kind: GrafanaHealthCheckKind;
30
+ readonly url: URL;
31
+ readonly fallbackUrl?: URL;
32
+ }
33
+
34
+ interface GrafanaDatasourceDefinition {
35
+ readonly label: string;
36
+ readonly key: GrafanaQueryDatasourceKey;
37
+ readonly uid: string;
38
+ readonly fallbackHealthPath?: string;
39
+ }
40
+
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
+ const datasourceDefinitions = [
78
+ { label: "Tempo datasource", key: "tempo", fallbackHealthPath: "/ready" },
79
+ { label: "Loki datasource", key: "loki", fallbackHealthPath: undefined },
80
+ { label: "Metrics datasource", key: "prometheus", fallbackHealthPath: undefined },
81
+ ] 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
+ export class GrafanaQueryClient {
94
+ readonly #config: ObservMeConfig;
95
+ readonly #transport: GrafanaTransportClient;
96
+
97
+ constructor(config: ObservMeConfig, options: GrafanaQueryClientOptions = {}) {
98
+ this.#config = config;
99
+ this.#transport = createGrafanaTransport(config, options);
100
+ }
101
+
102
+ async health(): Promise<GrafanaHealthResult> {
103
+ return getGrafanaHealthWithTransport(this.#config, this.#transport);
104
+ }
105
+
106
+ getTraceLink(traceId: string): string {
107
+ return getGrafanaTraceLink(this.#config, traceId);
108
+ }
109
+ }
110
+
111
+ export function createGrafanaQueryClient(
112
+ config: ObservMeConfig,
113
+ options: GrafanaQueryClientOptions = {},
114
+ ): GrafanaQueryClient {
115
+ return new GrafanaQueryClient(config, options);
116
+ }
117
+
118
+ export async function getGrafanaHealth(
119
+ config: ObservMeConfig,
120
+ options: GrafanaQueryClientOptions = {},
121
+ ): Promise<GrafanaHealthResult> {
122
+ return getGrafanaHealthWithTransport(config, createGrafanaTransport(config, options));
123
+ }
124
+
125
+ async function getGrafanaHealthWithTransport(
126
+ config: ObservMeConfig,
127
+ transport: GrafanaTransportClient,
128
+ ): Promise<GrafanaHealthResult> {
129
+ const checks = await Promise.all([
130
+ checkGrafanaReachability(config, transport),
131
+ ...createDatasourceDefinitions(config).map(datasource => checkDatasourceReachability(config, datasource, transport)),
132
+ ]);
133
+
134
+ return { timeoutMs: transport.timeoutMs, checks };
135
+ }
136
+
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
+ async function checkGrafanaReachability(
148
+ config: ObservMeConfig,
149
+ transport: GrafanaTransportClient,
150
+ ): Promise<GrafanaHealthCheckResult> {
151
+ const preflight = resolveGrafanaPreflightResult(config, "Grafana", "service");
152
+ if (preflight) return preflight;
153
+
154
+ try {
155
+ const target = createGrafanaHealthTarget(transport);
156
+ return await checkGrafanaTarget(target, transport);
157
+ } catch (error) {
158
+ return failedHealthResult("Grafana", "service", error);
159
+ }
160
+ }
161
+
162
+ async function checkDatasourceReachability(
163
+ config: ObservMeConfig,
164
+ datasource: GrafanaDatasourceDefinition,
165
+ transport: GrafanaTransportClient,
166
+ ): Promise<GrafanaHealthCheckResult> {
167
+ const preflight = resolveGrafanaPreflightResult(config, datasource.label, "datasource", datasource.key);
168
+ if (preflight) return preflight;
169
+
170
+ try {
171
+ const target = createDatasourceHealthTarget(transport, datasource);
172
+ return await checkGrafanaTarget(target, transport);
173
+ } catch (error) {
174
+ return failedHealthResult(datasource.label, "datasource", error);
175
+ }
176
+ }
177
+
178
+ async function checkGrafanaTarget(
179
+ target: GrafanaHealthTarget,
180
+ transport: GrafanaTransportClient,
181
+ ): Promise<GrafanaHealthCheckResult> {
182
+ try {
183
+ const response = await transport.fetch(target.url);
184
+ if (!shouldFetchGrafanaFallbackTarget(target, response)) return responseToHealthResult(target, response, transport);
185
+ return responseToHealthResult(target, await fetchGrafanaFallbackTarget(target, transport), transport);
186
+ } catch (error) {
187
+ return failedHealthResult(target.label, target.kind, error);
188
+ }
189
+ }
190
+
191
+ function createGrafanaHealthTarget(transport: GrafanaTransportClient): GrafanaHealthTarget {
192
+ return {
193
+ label: "Grafana",
194
+ kind: "service",
195
+ url: transport.apiUrl("/api/health"),
196
+ };
197
+ }
198
+
199
+ function createDatasourceHealthTarget(
200
+ transport: GrafanaTransportClient,
201
+ datasource: GrafanaDatasourceDefinition,
202
+ ): GrafanaHealthTarget {
203
+ return {
204
+ label: datasource.label,
205
+ kind: "datasource",
206
+ url: transport.datasourceApiUrl(datasource.uid, "/health"),
207
+ fallbackUrl: createDatasourceFallbackHealthUrl(transport, datasource),
208
+ };
209
+ }
210
+
211
+ function createDatasourceFallbackHealthUrl(
212
+ transport: GrafanaTransportClient,
213
+ datasource: GrafanaDatasourceDefinition,
214
+ ): URL | undefined {
215
+ if (!datasource.fallbackHealthPath) return undefined;
216
+
217
+ return transport.datasourceProxyUrl(datasource.uid, datasource.fallbackHealthPath);
218
+ }
219
+
220
+ function createDatasourceDefinitions(config: ObservMeConfig): GrafanaDatasourceDefinition[] {
221
+ return datasourceDefinitions.map(definition => ({
222
+ label: definition.label,
223
+ key: definition.key,
224
+ uid: config.query.grafana.datasourceUids[definition.key],
225
+ fallbackHealthPath: definition.fallbackHealthPath,
226
+ }));
227
+ }
228
+
229
+ function resolveGrafanaPreflightResult(
230
+ config: ObservMeConfig,
231
+ label: string,
232
+ kind: GrafanaHealthCheckKind,
233
+ datasourceKey?: GrafanaQueryDatasourceKey,
234
+ ): GrafanaHealthCheckResult | undefined {
235
+ const readiness = getGrafanaQueryReadiness(config, datasourceKey);
236
+ if (readiness.status === "ready") return undefined;
237
+ if (readiness.status === "disabled") return skippedHealthResult(label, kind, "query disabled");
238
+
239
+ return {
240
+ label,
241
+ kind,
242
+ status: "failed",
243
+ detail: formatGrafanaQueryReadiness(readiness),
244
+ };
245
+ }
246
+
247
+ function responseToHealthResult(
248
+ target: GrafanaHealthTarget,
249
+ response: Response,
250
+ transport: GrafanaTransportClient,
251
+ ): GrafanaHealthCheckResult {
252
+ if (response.ok) return { label: target.label, kind: target.kind, status: "ok" };
253
+
254
+ return {
255
+ label: target.label,
256
+ kind: target.kind,
257
+ status: "failed",
258
+ detail: transport.formatHttpFailure(response),
259
+ };
260
+ }
261
+
262
+ function shouldFetchGrafanaFallbackTarget(target: GrafanaHealthTarget, response: Response): boolean {
263
+ return target.kind === "datasource" && response.status === 404 && target.fallbackUrl !== undefined;
264
+ }
265
+
266
+ async function fetchGrafanaFallbackTarget(
267
+ target: GrafanaHealthTarget,
268
+ transport: GrafanaTransportClient,
269
+ ): Promise<Response> {
270
+ const fallbackUrl = target.fallbackUrl;
271
+ if (!fallbackUrl) throw new Error("Grafana datasource fallback health URL is not configured.");
272
+ return transport.fetch(fallbackUrl);
273
+ }
274
+
275
+ function failedHealthResult(label: string, kind: GrafanaHealthCheckKind, error: unknown): GrafanaHealthCheckResult {
276
+ return {
277
+ label,
278
+ kind,
279
+ status: "failed",
280
+ detail: formatHealthFailure(error),
281
+ };
282
+ }
283
+
284
+ function skippedHealthResult(label: string, kind: GrafanaHealthCheckKind, detail: string): GrafanaHealthCheckResult {
285
+ return { label, kind, status: "skipped", detail };
286
+ }
287
+
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
+ function formatHealthFailure(error: unknown): string {
366
+ return formatError(error);
367
+ }
368
+
369
+ function formatError(error: unknown): string {
370
+ return sanitizeDiagnosticText(readDiagnosticMessage(error));
371
+ }
@@ -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
+
7
+ export type LokiFetch = GrafanaFetch;
8
+
9
+ export interface TimeRange {
10
+ readonly from: Date;
11
+ readonly to: Date;
12
+ }
13
+
14
+ export interface LogResult {
15
+ readonly timestampUnixNano: string;
16
+ readonly line: string;
17
+ readonly labels: Record<string, string>;
18
+ readonly metadata?: Record<string, string>;
19
+ }
20
+
21
+ export interface LokiQueryClientOptions {
22
+ readonly fetch?: LokiFetch;
23
+ }
24
+
25
+ interface NormalizedTimeRange {
26
+ readonly from: Date;
27
+ readonly to: Date;
28
+ }
29
+
30
+ const minimumMaxLogs = 1;
31
+ const maxLogQlQueryLength = 4096;
32
+ const safeLokiAttributeNamePattern = /^[A-Za-z_][A-Za-z0-9_.]*$/u;
33
+ const lokiIdentifierStartPattern = /^[A-Za-z_]$/u;
34
+ const lokiIdentifierPartPattern = /^[A-Za-z0-9_.]$/u;
35
+
36
+ export class LokiQueryClient {
37
+ readonly #config: ObservMeConfig;
38
+ readonly #transport: GrafanaTransportClient;
39
+
40
+ constructor(config: ObservMeConfig, options: LokiQueryClientOptions = {}) {
41
+ this.#config = config;
42
+ this.#transport = createGrafanaTransport(config, options);
43
+ }
44
+
45
+ async queryLoki(query: string, range: TimeRange): Promise<LogResult[]> {
46
+ return queryLokiWithTransport(this.#config, this.#transport, query, range);
47
+ }
48
+ }
49
+
50
+ export function createLokiQueryClient(
51
+ config: ObservMeConfig,
52
+ options: LokiQueryClientOptions = {},
53
+ ): LokiQueryClient {
54
+ return new LokiQueryClient(config, options);
55
+ }
56
+
57
+ export async function queryLoki(
58
+ config: ObservMeConfig,
59
+ query: string,
60
+ range: TimeRange,
61
+ options: LokiQueryClientOptions = {},
62
+ ): Promise<LogResult[]> {
63
+ return queryLokiWithTransport(config, createGrafanaTransport(config, options), query, range);
64
+ }
65
+
66
+ async function queryLokiWithTransport(
67
+ config: ObservMeConfig,
68
+ transport: GrafanaTransportClient,
69
+ query: string,
70
+ range: TimeRange,
71
+ ): Promise<LogResult[]> {
72
+ if (!config.query.enabled) return [];
73
+
74
+ assertGrafanaQueryReady(config, "loki");
75
+ const normalizedQuery = normalizeLokiQuery(query);
76
+ const timeRange = normalizeTimeRange(range);
77
+ const maxLogs = resolveMaxLogs(config);
78
+ const url = createLokiQueryRangeUrl(config, transport, normalizedQuery, timeRange, maxLogs);
79
+ const response = await transport.fetch(url, { timeoutMessage: "Loki query timed out." });
80
+
81
+ return readLokiLogResults(response, transport, maxLogs);
82
+ }
83
+
84
+ export function normalizeLokiAttributeName(attributeName: string): string {
85
+ const normalizedName = attributeName.trim();
86
+ if (!safeLokiAttributeNamePattern.test(normalizedName)) throw new Error(`Unsafe Loki attribute name: ${attributeName}`);
87
+ return normalizedName.replaceAll(".", "_");
88
+ }
89
+
90
+ export function normalizeLokiQueryAttributes(query: string): string {
91
+ let normalizedQuery = "";
92
+ let index = 0;
93
+ let quote: string | undefined;
94
+ let escaped = false;
95
+
96
+ while (index < query.length) {
97
+ const char = query[index];
98
+
99
+ if (quote) {
100
+ normalizedQuery += char;
101
+ quote = nextLokiQuote(quote, char, escaped);
102
+ escaped = nextLokiEscapeState(char, escaped);
103
+ index += 1;
104
+ continue;
105
+ }
106
+
107
+ if (isLokiQuote(char)) {
108
+ quote = char;
109
+ normalizedQuery += char;
110
+ index += 1;
111
+ continue;
112
+ }
113
+
114
+ if (isLokiIdentifierStart(char)) {
115
+ const token = readLokiIdentifier(query, index);
116
+ normalizedQuery += normalizeLokiQueryToken(token);
117
+ index += token.length;
118
+ continue;
119
+ }
120
+
121
+ normalizedQuery += char;
122
+ index += 1;
123
+ }
124
+
125
+ return normalizedQuery;
126
+ }
127
+
128
+ function nextLokiQuote(quote: string, char: string, escaped: boolean): string | undefined {
129
+ if (escaped || char !== quote) return quote;
130
+ return undefined;
131
+ }
132
+
133
+ function nextLokiEscapeState(char: string, escaped: boolean): boolean {
134
+ if (escaped) return false;
135
+ return char === "\\";
136
+ }
137
+
138
+ function normalizeLokiQuery(query: string): string {
139
+ const trimmed = query.trim();
140
+ if (!trimmed) throw new Error("Loki query requires a non-empty LogQL query.");
141
+ if (trimmed.length > maxLogQlQueryLength) throw new Error("Loki query is bounded to 4096 characters.");
142
+
143
+ assertNoSensitiveQueryInput(trimmed, "Loki query");
144
+ return normalizeLokiQueryAttributes(trimmed);
145
+ }
146
+
147
+ function normalizeLokiQueryToken(token: string): string {
148
+ return token.includes(".") ? normalizeLokiAttributeName(token) : token;
149
+ }
150
+
151
+ function readLokiIdentifier(query: string, start: number): string {
152
+ let end = start + 1;
153
+
154
+ while (end < query.length && isLokiIdentifierPart(query[end])) end += 1;
155
+ return query.slice(start, end);
156
+ }
157
+
158
+ function isLokiQuote(char: string): boolean {
159
+ return char === '"' || char === "'" || char === "`";
160
+ }
161
+
162
+ function isLokiIdentifierStart(char: string): boolean {
163
+ return lokiIdentifierStartPattern.test(char);
164
+ }
165
+
166
+ function isLokiIdentifierPart(char: string): boolean {
167
+ return lokiIdentifierPartPattern.test(char);
168
+ }
169
+
170
+ function normalizeTimeRange(range: TimeRange): NormalizedTimeRange {
171
+ const from = normalizeDate(range.from, "from");
172
+ const to = normalizeDate(range.to, "to");
173
+
174
+ if (from.getTime() > to.getTime()) throw new Error("Loki query range must have from <= to.");
175
+ return { from, to };
176
+ }
177
+
178
+ function normalizeDate(value: Date, label: string): Date {
179
+ if (!(value instanceof Date) || !Number.isFinite(value.getTime())) {
180
+ throw new TypeError(`Loki query range ${label} must be a valid Date.`);
181
+ }
182
+
183
+ return value;
184
+ }
185
+
186
+ function createLokiQueryRangeUrl(
187
+ config: ObservMeConfig,
188
+ transport: GrafanaTransportClient,
189
+ query: string,
190
+ range: NormalizedTimeRange,
191
+ maxLogs: number,
192
+ ): URL {
193
+ const url = transport.datasourceProxyUrl(config.query.grafana.datasourceUids.loki, "/loki/api/v1/query_range");
194
+
195
+ url.searchParams.set("query", query);
196
+ url.searchParams.set("start", formatEpochNanoseconds(range.from));
197
+ url.searchParams.set("end", formatEpochNanoseconds(range.to));
198
+ url.searchParams.set("limit", String(maxLogs));
199
+ url.searchParams.set("direction", "backward");
200
+ return url;
201
+ }
202
+
203
+ function formatEpochNanoseconds(date: Date): string {
204
+ return (BigInt(date.getTime()) * 1_000_000n).toString();
205
+ }
206
+
207
+ async function readLokiLogResults(
208
+ response: Response,
209
+ transport: GrafanaTransportClient,
210
+ maxLogs: number,
211
+ ): Promise<LogResult[]> {
212
+ if (!response.ok) throw new Error(`Loki query failed: ${transport.formatHttpFailure(response)}`);
213
+
214
+ const payload = (await response.json()) as unknown;
215
+ const apiError = readLokiApiError(payload);
216
+ if (apiError) throw new Error(`Loki query failed: ${apiError}`);
217
+
218
+ const logs = extractLokiLogResults(payload);
219
+ return logs.slice(0, maxLogs);
220
+ }
221
+
222
+ function readLokiApiError(payload: unknown): string | undefined {
223
+ if (!isRecord(payload) || payload.status !== "error") return undefined;
224
+
225
+ const errorType = readOptionalString(payload, "errorType");
226
+ const errorMessage = readOptionalString(payload, "error");
227
+ return [errorType, errorMessage].filter(isNonEmptyString).join(": ") || "unknown Loki API error";
228
+ }
229
+
230
+ function createLokiSchemaError(reason: string): Error {
231
+ return new Error(`Loki query failed: backend schema error: ${reason}.`);
232
+ }
233
+
234
+ function extractLokiLogResults(payload: unknown): LogResult[] {
235
+ const data = readLokiSuccessData(payload);
236
+ return extractLokiStreams(data.result).flatMap(toLogResultsFromStream);
237
+ }
238
+
239
+ function readLokiSuccessData(payload: unknown): Record<string, unknown> {
240
+ if (!isRecord(payload)) throw createLokiSchemaError("response must be a JSON object");
241
+ if (payload.status !== "success") throw createLokiSchemaError("status must be success");
242
+ if (!isRecord(payload.data)) throw createLokiSchemaError("data must be an object");
243
+ if (payload.data.resultType !== "streams") throw createLokiSchemaError("data.resultType must be streams");
244
+ return payload.data;
245
+ }
246
+
247
+ function extractLokiStreams(result: unknown): unknown[] {
248
+ if (!Array.isArray(result)) throw createLokiSchemaError("data.result must be an array of streams");
249
+ return result;
250
+ }
251
+
252
+ function toLogResultsFromStream(stream: unknown): LogResult[] {
253
+ if (!isRecord(stream)) throw createLokiSchemaError("each data.result stream must be an object");
254
+
255
+ const labels = readLokiStreamLabels(stream.stream);
256
+ const values = readLokiStreamValues(stream.values);
257
+ return values.map(value => toLogResult(value, labels));
258
+ }
259
+
260
+ function readLokiStreamLabels(value: unknown): Record<string, string> {
261
+ if (!isRecord(value)) throw createLokiSchemaError("each data.result stream labels must be an object");
262
+ return readStringRecord(value);
263
+ }
264
+
265
+ function readLokiStreamValues(value: unknown): unknown[] {
266
+ if (!Array.isArray(value)) throw createLokiSchemaError("each data.result stream values must be an array");
267
+ if (value.length === 0) throw createLokiSchemaError("each data.result stream values array must include at least one log entry");
268
+ return value;
269
+ }
270
+
271
+ function toLogResult(value: unknown, labels: Record<string, string>): LogResult {
272
+ if (!Array.isArray(value) || value.length < 2) throw createLokiSchemaError("Loki log values must be [timestamp, line] pairs");
273
+
274
+ const timestampUnixNano = readNonEmptyStringOrNumber(value[0]);
275
+ const line = readStringOrNumber(value[1]);
276
+ if (!timestampUnixNano || line === undefined) throw createLokiSchemaError("Loki log values must include timestamp and line strings");
277
+
278
+ const metadata = readLokiMetadata(value[2]);
279
+ const result = { timestampUnixNano, line, labels: { ...labels } };
280
+ return Object.keys(metadata).length > 0 ? { ...result, metadata } : result;
281
+ }
282
+
283
+ function readLokiMetadata(value: unknown): Record<string, string> {
284
+ if (value === undefined) return {};
285
+ if (!isRecord(value)) throw createLokiSchemaError("Loki log metadata must be an object when present");
286
+ return readStringRecord(value);
287
+ }
288
+
289
+ function readStringRecord(value: unknown): Record<string, string> {
290
+ const record: Record<string, string> = {};
291
+ if (!isRecord(value)) return record;
292
+
293
+ for (const [key, item] of Object.entries(value)) {
294
+ const text = readStringOrNumber(item);
295
+ if (text !== undefined) record[key] = text;
296
+ }
297
+
298
+ return record;
299
+ }
300
+
301
+ function readOptionalString(item: Record<string, unknown>, key: string): string | undefined {
302
+ const value = item[key];
303
+ if (typeof value !== "string") return undefined;
304
+
305
+ const trimmed = value.trim();
306
+ return trimmed || undefined;
307
+ }
308
+
309
+ function readNonEmptyStringOrNumber(value: unknown): string | undefined {
310
+ const text = readStringOrNumber(value)?.trim();
311
+ return text || undefined;
312
+ }
313
+
314
+ function readStringOrNumber(value: unknown): string | undefined {
315
+ if (typeof value === "string") return value;
316
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
317
+ return undefined;
318
+ }
319
+
320
+ function isNonEmptyString(value: string | undefined): value is string {
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 resolveMaxLogs(config: ObservMeConfig): number {
329
+ const maxLogs = config.query.maxLogs;
330
+ if (!Number.isFinite(maxLogs) || maxLogs < minimumMaxLogs) return minimumMaxLogs;
331
+ return Math.trunc(maxLogs);
332
+ }