@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
@@ -1,4 +1,8 @@
1
- import { isAbsolute, join, relative, resolve } from "node:path";
1
+ import { constants } from "node:fs";
2
+ import type { BigIntStats } from "node:fs";
3
+ import { lstat, mkdir, open, realpath, stat, unlink } from "node:fs/promises";
4
+ import type { FileHandle } from "node:fs/promises";
5
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
2
6
 
3
7
  export interface ProjectLocalFilePathOptions {
4
8
  readonly cwd?: string;
@@ -9,17 +13,107 @@ export interface ProjectLocalFilePathOptions {
9
13
  readonly inputLabel: string;
10
14
  }
11
15
 
16
+ export interface ProjectLocalFileOperationHooks {
17
+ readonly beforeOpen?: () => Promise<void> | void;
18
+ }
19
+
20
+ export type ExclusiveProjectLocalFileCreateStatus = "created" | "exists";
21
+
22
+ interface FileIdentity {
23
+ readonly device: bigint;
24
+ readonly inode: bigint;
25
+ }
26
+
27
+ interface CanonicalProjectPathSnapshot {
28
+ readonly rootPath: string;
29
+ readonly canonicalRootPath: string;
30
+ readonly canonicalCandidatePath: string;
31
+ readonly rootIdentity: FileIdentity;
32
+ readonly inputLabel: string;
33
+ }
34
+
12
35
  const parentSegment = "..";
36
+ const unsafeProjectPathErrorCode = "OBSERVME_UNSAFE_PROJECT_PATH";
13
37
 
14
38
  export function resolveProjectLocalFilePath(options: ProjectLocalFilePathOptions): string {
15
39
  const root = resolve(options.cwd ?? process.cwd());
16
40
  const candidatePath = resolveProjectLocalCandidatePath(root, options);
17
41
 
18
- if (!isPathInsideOrSame(root, candidatePath)) throw new Error(createUnsafeProjectPathMessage(options.inputLabel));
42
+ assertProjectContainment(root, candidatePath, options.inputLabel);
19
43
  if (shouldPreserveRelativeOverride(options)) return options.overridePath!;
20
44
  return candidatePath;
21
45
  }
22
46
 
47
+ /**
48
+ * Project-local I/O policy: lexical paths and their canonical targets must remain
49
+ * inside one stable canonical root. Safe in-root symlinks are pinned to their
50
+ * canonical target, missing final paths are supported, and an opened file's
51
+ * device/inode identity is verified before bytes are read or written.
52
+ */
53
+ export async function resolveCanonicalProjectLocalFilePath(
54
+ options: ProjectLocalFilePathOptions,
55
+ ): Promise<string> {
56
+ return (await createCanonicalProjectPathSnapshot(options)).canonicalCandidatePath;
57
+ }
58
+
59
+ export async function readCanonicalProjectLocalFileText(
60
+ options: ProjectLocalFilePathOptions,
61
+ hooks: ProjectLocalFileOperationHooks = {},
62
+ ): Promise<string | undefined> {
63
+ const snapshot = await createCanonicalProjectPathSnapshot(options);
64
+ await hooks.beforeOpen?.();
65
+
66
+ let fileHandle: FileHandle | undefined;
67
+
68
+ try {
69
+ fileHandle = await openProjectFileForRead(snapshot);
70
+ if (!fileHandle) return undefined;
71
+ await assertOpenedProjectFileIsStable(snapshot, fileHandle);
72
+ return await fileHandle.readFile({ encoding: "utf8" });
73
+ } finally {
74
+ await closeFileHandle(fileHandle);
75
+ }
76
+ }
77
+
78
+ export async function createCanonicalProjectLocalFileExclusively(
79
+ options: ProjectLocalFilePathOptions,
80
+ content: string,
81
+ hooks: ProjectLocalFileOperationHooks = {},
82
+ ): Promise<ExclusiveProjectLocalFileCreateStatus> {
83
+ const initialSnapshot = await createCanonicalProjectPathSnapshot(options);
84
+ await mkdir(dirname(initialSnapshot.canonicalCandidatePath), { recursive: true });
85
+ const snapshot = await createCanonicalProjectPathSnapshot(options);
86
+ await hooks.beforeOpen?.();
87
+
88
+ let fileHandle: FileHandle | undefined;
89
+ let openedIdentity: FileIdentity | undefined;
90
+
91
+ try {
92
+ fileHandle = await openProjectFileForExclusiveCreate(snapshot);
93
+ if (!fileHandle) {
94
+ await assertExistingProjectFileIsSafe(options);
95
+ return "exists";
96
+ }
97
+
98
+ openedIdentity = toFileIdentity(await fileHandle.stat({ bigint: true }));
99
+ await assertOpenedProjectFileIsStable(snapshot, fileHandle);
100
+ await fileHandle.writeFile(content, { encoding: "utf8" });
101
+ await assertOpenedProjectFileIsStable(snapshot, fileHandle);
102
+ return "created";
103
+ } catch (error) {
104
+ await closeFileHandle(fileHandle);
105
+ fileHandle = undefined;
106
+ if (openedIdentity) await removeCreatedFileIfIdentityMatches(snapshot, openedIdentity);
107
+ throw error;
108
+ } finally {
109
+ await closeFileHandle(fileHandle);
110
+ }
111
+ }
112
+
113
+ export function isUnsafeProjectPathError(error: unknown): boolean {
114
+ return isErrorWithCode(error) && error.code === unsafeProjectPathErrorCode;
115
+ }
116
+
23
117
  function resolveProjectLocalCandidatePath(root: string, options: ProjectLocalFilePathOptions): string {
24
118
  if (options.overridePath !== undefined) return resolveOverridePath(root, options.overridePath, options.inputLabel);
25
119
  return resolveConfigDirPath(root, options);
@@ -30,16 +124,16 @@ function shouldPreserveRelativeOverride(options: ProjectLocalFilePathOptions): b
30
124
  }
31
125
 
32
126
  function resolveOverridePath(root: string, overridePath: string, inputLabel: string): string {
33
- if (isRelativeTraversalPath(overridePath)) throw new Error(createUnsafeProjectPathMessage(inputLabel));
127
+ if (isRelativeTraversalPath(overridePath)) throw createUnsafeProjectPathError(inputLabel);
34
128
  return isAbsolute(overridePath) ? resolve(overridePath) : resolve(root, overridePath);
35
129
  }
36
130
 
37
131
  function resolveConfigDirPath(root: string, options: ProjectLocalFilePathOptions): string {
38
132
  const configDirName = options.configDirName ?? options.defaultConfigDirName;
39
133
 
40
- if (!configDirName) throw new Error(createUnsafeProjectPathMessage(options.inputLabel));
41
- if (isAbsolute(configDirName)) throw new Error(createUnsafeProjectPathMessage(options.inputLabel));
42
- if (hasParentSegment(configDirName)) throw new Error(createUnsafeProjectPathMessage(options.inputLabel));
134
+ if (!configDirName) throw createUnsafeProjectPathError(options.inputLabel);
135
+ if (isAbsolute(configDirName)) throw createUnsafeProjectPathError(options.inputLabel);
136
+ if (hasParentSegment(configDirName)) throw createUnsafeProjectPathError(options.inputLabel);
43
137
  return resolve(root, join(configDirName, options.fileName));
44
138
  }
45
139
 
@@ -51,11 +145,229 @@ function hasParentSegment(pathInput: string): boolean {
51
145
  return pathInput.split(/[\\/]+/u).includes(parentSegment);
52
146
  }
53
147
 
148
+ function assertProjectContainment(root: string, candidatePath: string, inputLabel: string): void {
149
+ if (!isPathInsideOrSame(root, candidatePath)) throw createUnsafeProjectPathError(inputLabel);
150
+ }
151
+
152
+ async function createCanonicalProjectPathSnapshot(
153
+ options: ProjectLocalFilePathOptions,
154
+ ): Promise<CanonicalProjectPathSnapshot> {
155
+ try {
156
+ const rootPath = resolve(options.cwd ?? process.cwd());
157
+ const candidatePath = resolveProjectLocalCandidatePath(rootPath, options);
158
+ assertProjectContainment(rootPath, candidatePath, options.inputLabel);
159
+
160
+ const canonicalRootPath = await realpath(rootPath);
161
+ const rootStats = await stat(canonicalRootPath, { bigint: true });
162
+ if (!rootStats.isDirectory()) throw createUnsafeProjectPathError(options.inputLabel);
163
+
164
+ const canonicalCandidatePath = await resolveCanonicalPathWithMissingComponents(candidatePath);
165
+ assertProjectContainment(canonicalRootPath, canonicalCandidatePath, options.inputLabel);
166
+
167
+ return {
168
+ rootPath,
169
+ canonicalRootPath,
170
+ canonicalCandidatePath,
171
+ rootIdentity: toFileIdentity(rootStats),
172
+ inputLabel: options.inputLabel,
173
+ };
174
+ } catch (error) {
175
+ if (isUnsafeProjectPathError(error)) throw error;
176
+ throw createUnsafeProjectPathError(options.inputLabel);
177
+ }
178
+ }
179
+
180
+ async function openProjectFileForRead(snapshot: CanonicalProjectPathSnapshot): Promise<FileHandle | undefined> {
181
+ try {
182
+ return await open(snapshot.canonicalCandidatePath, createReadOpenFlags());
183
+ } catch (error) {
184
+ if (!isMissingPathError(error) && !isNotDirectoryError(error)) {
185
+ if (isSymlinkLoopError(error)) throw createUnsafeProjectPathError(snapshot.inputLabel);
186
+ throw error;
187
+ }
188
+
189
+ await assertMissingProjectPathIsSafe(snapshot);
190
+ return undefined;
191
+ }
192
+ }
193
+
194
+ async function openProjectFileForExclusiveCreate(
195
+ snapshot: CanonicalProjectPathSnapshot,
196
+ ): Promise<FileHandle | undefined> {
197
+ try {
198
+ return await open(snapshot.canonicalCandidatePath, createExclusiveWriteOpenFlags(), 0o600);
199
+ } catch (error) {
200
+ if (isFileAlreadyExistsError(error)) return undefined;
201
+ if (isSymlinkLoopError(error)) throw createUnsafeProjectPathError(snapshot.inputLabel);
202
+ throw error;
203
+ }
204
+ }
205
+
206
+ function createReadOpenFlags(): number {
207
+ return constants.O_RDONLY | optionalOpenFlag(constants.O_NOFOLLOW) | optionalOpenFlag(constants.O_NONBLOCK);
208
+ }
209
+
210
+ function createExclusiveWriteOpenFlags(): number {
211
+ return constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | optionalOpenFlag(constants.O_NOFOLLOW);
212
+ }
213
+
214
+ function optionalOpenFlag(flag: number | undefined): number {
215
+ return typeof flag === "number" ? flag : 0;
216
+ }
217
+
218
+ async function assertOpenedProjectFileIsStable(
219
+ snapshot: CanonicalProjectPathSnapshot,
220
+ fileHandle: FileHandle,
221
+ ): Promise<void> {
222
+ try {
223
+ await assertCanonicalRootIsStable(snapshot);
224
+ const currentCanonicalPath = await realpath(snapshot.canonicalCandidatePath);
225
+ assertProjectContainment(snapshot.canonicalRootPath, currentCanonicalPath, snapshot.inputLabel);
226
+
227
+ const openedStats = await fileHandle.stat({ bigint: true });
228
+ const currentPathStats = await stat(currentCanonicalPath, { bigint: true });
229
+ if (!openedStats.isFile() || !currentPathStats.isFile()) {
230
+ throw createUnsafeProjectPathError(snapshot.inputLabel);
231
+ }
232
+ if (!hasSameFileIdentity(openedStats, currentPathStats)) {
233
+ throw createUnsafeProjectPathError(snapshot.inputLabel);
234
+ }
235
+ } catch (error) {
236
+ if (isUnsafeProjectPathError(error)) throw error;
237
+ throw createUnsafeProjectPathError(snapshot.inputLabel);
238
+ }
239
+ }
240
+
241
+ async function assertMissingProjectPathIsSafe(snapshot: CanonicalProjectPathSnapshot): Promise<void> {
242
+ try {
243
+ await assertCanonicalRootIsStable(snapshot);
244
+ const currentCanonicalPath = await resolveCanonicalPathWithMissingComponents(
245
+ snapshot.canonicalCandidatePath,
246
+ );
247
+ assertProjectContainment(snapshot.canonicalRootPath, currentCanonicalPath, snapshot.inputLabel);
248
+ } catch (error) {
249
+ if (isUnsafeProjectPathError(error)) throw error;
250
+ throw createUnsafeProjectPathError(snapshot.inputLabel);
251
+ }
252
+ }
253
+
254
+ async function assertExistingProjectFileIsSafe(options: ProjectLocalFilePathOptions): Promise<void> {
255
+ const snapshot = await createCanonicalProjectPathSnapshot(options);
256
+
257
+ try {
258
+ await assertCanonicalRootIsStable(snapshot);
259
+ const existingStats = await stat(snapshot.canonicalCandidatePath, { bigint: true });
260
+ if (!existingStats.isFile()) throw createUnsafeProjectPathError(snapshot.inputLabel);
261
+ } catch (error) {
262
+ if (isUnsafeProjectPathError(error)) throw error;
263
+ throw createUnsafeProjectPathError(snapshot.inputLabel);
264
+ }
265
+ }
266
+
267
+ async function assertCanonicalRootIsStable(snapshot: CanonicalProjectPathSnapshot): Promise<void> {
268
+ const currentCanonicalRoot = await realpath(snapshot.rootPath);
269
+ const currentRootStats = await stat(currentCanonicalRoot, { bigint: true });
270
+ if (!currentRootStats.isDirectory()) throw createUnsafeProjectPathError(snapshot.inputLabel);
271
+ if (!hasSameIdentity(snapshot.rootIdentity, toFileIdentity(currentRootStats))) {
272
+ throw createUnsafeProjectPathError(snapshot.inputLabel);
273
+ }
274
+ }
275
+
276
+ async function removeCreatedFileIfIdentityMatches(
277
+ snapshot: CanonicalProjectPathSnapshot,
278
+ openedIdentity: FileIdentity,
279
+ ): Promise<void> {
280
+ try {
281
+ const currentStats = await lstat(snapshot.canonicalCandidatePath, { bigint: true });
282
+ if (!hasSameIdentity(openedIdentity, toFileIdentity(currentStats))) return;
283
+ await unlink(snapshot.canonicalCandidatePath);
284
+ } catch {
285
+ return;
286
+ }
287
+ }
288
+
289
+ async function closeFileHandle(fileHandle: FileHandle | undefined): Promise<void> {
290
+ if (!fileHandle) return;
291
+
292
+ try {
293
+ await fileHandle.close();
294
+ } catch {
295
+ return;
296
+ }
297
+ }
298
+
299
+ function toFileIdentity(stats: BigIntStats): FileIdentity {
300
+ return { device: stats.dev, inode: stats.ino };
301
+ }
302
+
303
+ function hasSameFileIdentity(left: BigIntStats, right: BigIntStats): boolean {
304
+ return hasSameIdentity(toFileIdentity(left), toFileIdentity(right));
305
+ }
306
+
307
+ function hasSameIdentity(left: FileIdentity, right: FileIdentity): boolean {
308
+ return left.device === right.device && left.inode === right.inode;
309
+ }
310
+
311
+ async function resolveCanonicalPathWithMissingComponents(candidatePath: string): Promise<string> {
312
+ const missingComponents: string[] = [];
313
+ let currentPath = candidatePath;
314
+
315
+ while (true) {
316
+ try {
317
+ const canonicalExistingPath = await realpath(currentPath);
318
+ missingComponents.reverse();
319
+ return resolve(canonicalExistingPath, ...missingComponents);
320
+ } catch (error) {
321
+ if (!isMissingPathError(error) || await pathEntryExists(currentPath)) throw error;
322
+ const parentPath = dirname(currentPath);
323
+ if (parentPath === currentPath) throw error;
324
+ missingComponents.push(basename(currentPath));
325
+ currentPath = parentPath;
326
+ }
327
+ }
328
+ }
329
+
330
+ async function pathEntryExists(path: string): Promise<boolean> {
331
+ try {
332
+ await lstat(path);
333
+ return true;
334
+ } catch (error) {
335
+ if (isMissingPathError(error)) return false;
336
+ throw error;
337
+ }
338
+ }
339
+
340
+ function isMissingPathError(error: unknown): error is NodeJS.ErrnoException {
341
+ return isErrorWithCode(error) && error.code === "ENOENT";
342
+ }
343
+
344
+ function isNotDirectoryError(error: unknown): error is NodeJS.ErrnoException {
345
+ return isErrorWithCode(error) && error.code === "ENOTDIR";
346
+ }
347
+
348
+ function isSymlinkLoopError(error: unknown): error is NodeJS.ErrnoException {
349
+ return isErrorWithCode(error) && error.code === "ELOOP";
350
+ }
351
+
352
+ function isFileAlreadyExistsError(error: unknown): error is NodeJS.ErrnoException {
353
+ return isErrorWithCode(error) && error.code === "EEXIST";
354
+ }
355
+
356
+ function isErrorWithCode(error: unknown): error is NodeJS.ErrnoException {
357
+ return typeof error === "object" && error !== null && "code" in error;
358
+ }
359
+
54
360
  function isPathInsideOrSame(root: string, candidatePath: string): boolean {
55
361
  const pathFromRoot = relative(root, candidatePath);
56
362
  return pathFromRoot === "" || (!pathFromRoot.startsWith(parentSegment) && !isAbsolute(pathFromRoot));
57
363
  }
58
364
 
365
+ function createUnsafeProjectPathError(inputLabel: string): NodeJS.ErrnoException {
366
+ const error = new Error(createUnsafeProjectPathMessage(inputLabel)) as NodeJS.ErrnoException;
367
+ error.code = unsafeProjectPathErrorCode;
368
+ return error;
369
+ }
370
+
59
371
  function createUnsafeProjectPathMessage(inputLabel: string): string {
60
372
  return `Unsafe ObservMe ${inputLabel}: path input must stay inside the active project root.`;
61
373
  }
@@ -0,0 +1,10 @@
1
+ export const QUERY_RESULT_COUNT_MINIMUM = 1;
2
+ export const QUERY_RESULT_COUNT_MAXIMUM = 100;
3
+
4
+ export function normalizeQueryResultCount(value: number | undefined): number {
5
+ if (value === undefined || !Number.isFinite(value) || value < QUERY_RESULT_COUNT_MINIMUM) {
6
+ return QUERY_RESULT_COUNT_MINIMUM;
7
+ }
8
+
9
+ return Math.min(Math.trunc(value), QUERY_RESULT_COUNT_MAXIMUM);
10
+ }
@@ -1,5 +1,6 @@
1
1
  import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import { Type } from "typebox";
3
+ import { QUERY_RESULT_COUNT_MAXIMUM, QUERY_RESULT_COUNT_MINIMUM } from "./query-limits.ts";
3
4
 
4
5
  const observMeEnvironments = ["production", "development", "test"] as const;
5
6
  const privacyPathModes = ["hash", "basename", "full", "drop"] as const;
@@ -13,7 +14,6 @@ export const ACTIVE_AGENT_LEASE_DURATION_MILLIS_MAXIMUM = 300_000;
13
14
  export const ACTIVE_AGENT_LEASE_EXPORT_SAFETY_MARGIN_MILLIS = 5_000;
14
15
 
15
16
  export interface OtlpTlsConfig {
16
- enabled: boolean;
17
17
  insecureSkipVerify: boolean;
18
18
  }
19
19
 
@@ -178,7 +178,6 @@ export interface ObservMeConfig {
178
178
  enabled: boolean;
179
179
  environment: ObservMeEnvironment;
180
180
  tenant: string;
181
- replayOnStart: boolean;
182
181
  otlp: OtlpConfig;
183
182
  resource: ResourceConfig;
184
183
  workflow: WorkflowConfig;
@@ -197,6 +196,10 @@ const environmentSchema = StringEnum(observMeEnvironments);
197
196
  const otlpProtocolSchema = Type.Literal("http/protobuf");
198
197
  const stringRecordSchema = Type.Record(Type.String(), Type.String());
199
198
  const positiveIntegerSchema = Type.Integer({ minimum: 1 });
199
+ const queryResultCountSchema = Type.Integer({
200
+ minimum: QUERY_RESULT_COUNT_MINIMUM,
201
+ maximum: QUERY_RESULT_COUNT_MAXIMUM,
202
+ });
200
203
  const ratioSchema = Type.Number({ minimum: 0, maximum: 1 });
201
204
  const pathModeSchema = StringEnum(privacyPathModes);
202
205
 
@@ -213,7 +216,6 @@ export const observMeConfigSchema = Type.Object(
213
216
  enabled: Type.Boolean(),
214
217
  environment: environmentSchema,
215
218
  tenant: Type.String({ minLength: 1 }),
216
- replayOnStart: Type.Boolean(),
217
219
  otlp: Type.Object(
218
220
  {
219
221
  endpoint: Type.String({ minLength: 1 }),
@@ -222,7 +224,6 @@ export const observMeConfigSchema = Type.Object(
222
224
  headers: stringRecordSchema,
223
225
  tls: Type.Object(
224
226
  {
225
- enabled: Type.Boolean(),
226
227
  insecureSkipVerify: Type.Boolean(),
227
228
  },
228
229
  { additionalProperties: false },
@@ -361,10 +362,10 @@ export const observMeConfigSchema = Type.Object(
361
362
  {
362
363
  enabled: Type.Boolean(),
363
364
  timeoutMs: positiveIntegerSchema,
364
- maxLogs: positiveIntegerSchema,
365
- maxTraces: positiveIntegerSchema,
366
- maxMetricSeries: positiveIntegerSchema,
367
- maxAgents: positiveIntegerSchema,
365
+ maxLogs: queryResultCountSchema,
366
+ maxTraces: queryResultCountSchema,
367
+ maxMetricSeries: queryResultCountSchema,
368
+ maxAgents: queryResultCountSchema,
368
369
  links: Type.Object(
369
370
  {
370
371
  traceUrlTemplate: Type.String(),
@@ -0,0 +1,107 @@
1
+ import type { ObservMeConfig } from "./schema.ts";
2
+
3
+ const tlsVerificationEnabled = "TLS certificate verification enabled";
4
+ const tlsVerificationDisabled = "TLS certificate verification disabled";
5
+ const plaintextHttp = "plaintext HTTP";
6
+ const inactiveObservMe = "inactive (ObservMe disabled)";
7
+ const inactiveSignals = "inactive (all OTLP signals disabled)";
8
+ const inactiveGrafana = "inactive (Grafana queries disabled)";
9
+ const unknownTransport = "unknown or invalid transport";
10
+
11
+ interface OtlpSignalTransport {
12
+ readonly name: "traces" | "metrics" | "logs";
13
+ readonly endpoint: string;
14
+ readonly enabled: boolean;
15
+ }
16
+
17
+ export interface ObsTransportSecuritySnapshot {
18
+ readonly collector: string;
19
+ readonly grafana: string;
20
+ }
21
+
22
+ export function describeOtlpTransportSecurity(config: ObservMeConfig): string {
23
+ if (!config.enabled) return inactiveObservMe;
24
+
25
+ const signals = getOtlpSignalTransports(config).filter(signal => signal.enabled);
26
+ if (signals.length === 0) return inactiveSignals;
27
+
28
+ const descriptions = signals.map(signal => ({
29
+ name: signal.name,
30
+ description: describeEndpointTransportSecurity(
31
+ signal.endpoint,
32
+ config.otlp.tls.insecureSkipVerify,
33
+ config.privacy.allowInsecureTransport,
34
+ ),
35
+ }));
36
+ const uniqueDescriptions = new Set(descriptions.map(signal => signal.description));
37
+ if (uniqueDescriptions.size === 1) return descriptions[0]?.description ?? unknownTransport;
38
+
39
+ return descriptions.map(signal => `${signal.name}: ${signal.description}`).join(", ");
40
+ }
41
+
42
+ export function describeGrafanaTransportSecurity(config: ObservMeConfig): string {
43
+ if (!config.query.enabled) return inactiveGrafana;
44
+ return describeEndpointTransportSecurity(
45
+ config.query.grafana.url,
46
+ config.query.grafana.tls.insecureSkipVerify,
47
+ config.privacy.allowInsecureTransport,
48
+ );
49
+ }
50
+
51
+ export function createObsTransportSecuritySnapshot(config: ObservMeConfig): ObsTransportSecuritySnapshot {
52
+ return {
53
+ collector: describeEndpointTransportSecurity(
54
+ config.otlp.endpoint,
55
+ config.otlp.tls.insecureSkipVerify,
56
+ config.privacy.allowInsecureTransport,
57
+ ),
58
+ grafana: describeGrafanaTransportSecurity(config),
59
+ };
60
+ }
61
+
62
+ export function describeEndpointTransportSecurity(
63
+ endpoint: string,
64
+ insecureSkipVerify: boolean,
65
+ allowInsecureTransport: boolean,
66
+ ): string {
67
+ const protocol = readEndpointProtocol(endpoint);
68
+ if (protocol === "https:") {
69
+ return insecureSkipVerify
70
+ ? describeAcknowledgedInsecurity(tlsVerificationDisabled, allowInsecureTransport)
71
+ : tlsVerificationEnabled;
72
+ }
73
+ if (protocol === "http:") return describeAcknowledgedInsecurity(plaintextHttp, allowInsecureTransport);
74
+ return unknownTransport;
75
+ }
76
+
77
+ function getOtlpSignalTransports(config: ObservMeConfig): OtlpSignalTransport[] {
78
+ return [
79
+ {
80
+ name: "traces",
81
+ endpoint: config.otlp.signalEndpoints?.traces ?? config.otlp.endpoint,
82
+ enabled: config.traces.enabled,
83
+ },
84
+ {
85
+ name: "metrics",
86
+ endpoint: config.otlp.signalEndpoints?.metrics ?? config.otlp.endpoint,
87
+ enabled: config.metrics.enabled,
88
+ },
89
+ {
90
+ name: "logs",
91
+ endpoint: config.otlp.signalEndpoints?.logs ?? config.otlp.endpoint,
92
+ enabled: config.logs.enabled,
93
+ },
94
+ ];
95
+ }
96
+
97
+ function readEndpointProtocol(endpoint: string): string | undefined {
98
+ try {
99
+ return new URL(endpoint.trim()).protocol;
100
+ } catch {
101
+ return undefined;
102
+ }
103
+ }
104
+
105
+ function describeAcknowledgedInsecurity(description: string, allowInsecureTransport: boolean): string {
106
+ return allowInsecureTransport ? `${description} (explicitly acknowledged)` : description;
107
+ }
@@ -6,6 +6,11 @@ import {
6
6
  } from "./schema.ts";
7
7
  import type { ObservMeConfig } from "./schema.ts";
8
8
  import { validateCustomRedactionPatterns } from "../privacy/redact.ts";
9
+ import { classifyOtlpEndpointFailure } from "../otel/otlp-endpoint.ts";
10
+ import {
11
+ classifyGrafanaUrlSecurityFailure,
12
+ formatGrafanaUrlSecurityFailure,
13
+ } from "../query/grafana-url.ts";
9
14
 
10
15
  export interface ValidationIssue {
11
16
  code: string;
@@ -57,15 +62,22 @@ const maximumConfigDiagnosticIssueCount = 1_000_000;
57
62
  const unknownConfigValidationIssueCode = "unknown_config_validation_issue";
58
63
  const knownConfigValidationIssueCodes = new Set([
59
64
  "invalid_config_shape",
65
+ "config_source_malformed",
66
+ "config_source_rejected",
67
+ "config_source_unreadable",
60
68
  "unsafe_capture_without_redaction",
61
69
  "insecure_production_transport",
70
+ "invalid_otlp_endpoint",
62
71
  "invalid_signal_endpoint_path",
72
+ "embedded_grafana_url_credentials",
63
73
  "high_cardinality_metric_label",
64
74
  "active_agent_lease_too_short_for_export_interval",
65
75
  "custom_redaction_pattern_limit",
66
76
  "custom_redaction_pattern_too_long",
67
77
  "custom_redaction_pattern_unsupported_construct",
68
78
  "custom_redaction_pattern_nested_quantifier",
79
+ "custom_redaction_pattern_ambiguous_alternation",
80
+ "custom_redaction_pattern_ambiguous_repetition",
69
81
  "custom_redaction_pattern_empty_match",
70
82
  "invalid_custom_redaction_pattern",
71
83
  "untrusted_project_config_read",
@@ -84,7 +96,8 @@ export function validateObservMeConfig(
84
96
  const issues = [
85
97
  ...validateRedactionBoundary(config),
86
98
  ...validateTransportSecurity(config),
87
- ...validateSignalEndpoints(config),
99
+ ...validateOtlpEndpoints(config),
100
+ ...validateGrafanaUrl(config),
88
101
  ...validateMetricLabels(config),
89
102
  ...validateActiveAgentLeaseDuration(config),
90
103
  ...validateCustomRedactionPatternConfig(config),
@@ -253,7 +266,15 @@ function validateTransportSecurity(config: ObservMeConfig): ValidationIssue[] {
253
266
  ...validateProductionHttpEndpoint("otlp.signalEndpoints.traces", config.otlp.signalEndpoints?.traces),
254
267
  ...validateProductionHttpEndpoint("otlp.signalEndpoints.metrics", config.otlp.signalEndpoints?.metrics),
255
268
  ...validateProductionHttpEndpoint("otlp.signalEndpoints.logs", config.otlp.signalEndpoints?.logs),
269
+ ...validateProductionTlsVerificationBypass(
270
+ "otlp.tls.insecureSkipVerify",
271
+ config.otlp.tls.insecureSkipVerify,
272
+ ),
256
273
  ...validateProductionHttpEndpoint("query.grafana.url", config.query.grafana.url),
274
+ ...validateProductionTlsVerificationBypass(
275
+ "query.grafana.tls.insecureSkipVerify",
276
+ config.query.grafana.tls.insecureSkipVerify,
277
+ ),
257
278
  ];
258
279
  }
259
280
 
@@ -268,30 +289,54 @@ function validateProductionHttpEndpoint(name: string, endpoint: string | undefin
268
289
  ];
269
290
  }
270
291
 
292
+ function validateProductionTlsVerificationBypass(name: string, insecureSkipVerify: boolean): ValidationIssue[] {
293
+ if (!insecureSkipVerify) return [];
294
+
295
+ return [
296
+ {
297
+ code: "insecure_production_transport",
298
+ message: `${name} must be false in production unless privacy.allowInsecureTransport is true.`,
299
+ },
300
+ ];
301
+ }
302
+
271
303
  function isHttpEndpoint(endpoint: string): boolean {
272
304
  return endpoint.trim().toLowerCase().startsWith("http://");
273
305
  }
274
306
 
275
- function validateSignalEndpoints(config: ObservMeConfig): ValidationIssue[] {
276
- if (config.otlp.protocol !== "http/protobuf") return [];
277
-
307
+ function validateOtlpEndpoints(config: ObservMeConfig): ValidationIssue[] {
278
308
  const endpoints = config.otlp.signalEndpoints;
279
- if (!endpoints) return [];
280
309
 
281
310
  return [
282
- ...validateSignalEndpoint("traces", endpoints.traces, "/v1/traces"),
283
- ...validateSignalEndpoint("metrics", endpoints.metrics, "/v1/metrics"),
284
- ...validateSignalEndpoint("logs", endpoints.logs, "/v1/logs"),
311
+ ...validateOtlpEndpoint("otlp.endpoint", config.otlp.endpoint),
312
+ ...validateSignalEndpoint("otlp.signalEndpoints.traces", endpoints?.traces, "/v1/traces"),
313
+ ...validateSignalEndpoint("otlp.signalEndpoints.metrics", endpoints?.metrics, "/v1/metrics"),
314
+ ...validateSignalEndpoint("otlp.signalEndpoints.logs", endpoints?.logs, "/v1/logs"),
285
315
  ];
286
316
  }
287
317
 
288
- function validateSignalEndpoint(signal: string, endpoint: string | undefined, requiredPath: string): ValidationIssue[] {
289
- if (!endpoint || endpointPathMatches(endpoint, requiredPath)) return [];
318
+ function validateOtlpEndpoint(name: string, endpoint: string): ValidationIssue[] {
319
+ const failureClass = classifyOtlpEndpointFailure(endpoint);
320
+ if (!failureClass) return [];
321
+
322
+ return [
323
+ {
324
+ code: "invalid_otlp_endpoint",
325
+ message: `${name} is invalid (${failureClass}).`,
326
+ },
327
+ ];
328
+ }
329
+
330
+ function validateSignalEndpoint(name: string, endpoint: string | undefined, requiredPath: string): ValidationIssue[] {
331
+ if (!endpoint) return [];
332
+
333
+ const endpointIssues = validateOtlpEndpoint(name, endpoint);
334
+ if (endpointIssues.length > 0 || endpointPathMatches(endpoint, requiredPath)) return endpointIssues;
290
335
 
291
336
  return [
292
337
  {
293
338
  code: "invalid_signal_endpoint_path",
294
- message: `${signal} OTLP HTTP exporter URL must include ${requiredPath}.`,
339
+ message: `${name} must end with ${requiredPath}.`,
295
340
  },
296
341
  ];
297
342
  }
@@ -304,6 +349,18 @@ function endpointPathMatches(endpoint: string, requiredPath: string): boolean {
304
349
  }
305
350
  }
306
351
 
352
+ function validateGrafanaUrl(config: ObservMeConfig): ValidationIssue[] {
353
+ const failureClass = classifyGrafanaUrlSecurityFailure(config.query.grafana.url);
354
+ if (!failureClass) return [];
355
+
356
+ return [
357
+ {
358
+ code: "embedded_grafana_url_credentials",
359
+ message: formatGrafanaUrlSecurityFailure(failureClass),
360
+ },
361
+ ];
362
+ }
363
+
307
364
  function validateMetricLabels(config: ObservMeConfig): ValidationIssue[] {
308
365
  const labels = config.metrics.labels ?? [];
309
366
  return labels.filter(isForbiddenMetricLabel).map(label => ({