@senad-d/observme 0.1.3 → 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 (83) hide show
  1. package/.env.example +4 -0
  2. package/CHANGELOG.md +48 -0
  3. package/README.md +36 -11
  4. package/dashboards/observme-agent-node-graphs.json +5 -5
  5. package/dashboards/observme-agents.json +169 -4
  6. package/dashboards/observme-alerts.yaml +16 -3
  7. package/dashboards/observme-overview.json +6 -6
  8. package/dashboards/observme-trace-journey.json +4 -4
  9. package/docs/agent-subagent-observability-requirements.md +19 -10
  10. package/docs/compatibility-matrix.md +21 -4
  11. package/docs/configuration.md +7 -2
  12. package/docs/extension-integration.md +20 -13
  13. package/docs/reference/03-pi-event-and-session-model.md +6 -6
  14. package/docs/reference/04-telemetry-semantic-conventions.md +39 -1
  15. package/docs/reference/05-otel-pipeline-and-collector.md +23 -10
  16. package/docs/reference/06-security-privacy-redaction.md +13 -6
  17. package/docs/reference/08-query-grafana-integration.md +30 -7
  18. package/docs/reference/09-dashboards-alerts-slos.md +132 -3
  19. package/docs/reference/10-testing-release-operations.md +44 -14
  20. package/docs/reference/11-deployment-runbooks.md +82 -5
  21. package/docs/reference/12-configuration-reference.md +48 -5
  22. package/docs/review-validation.md +17 -0
  23. package/examples/README.md +7 -2
  24. package/examples/collector.yaml +8 -8
  25. package/examples/integrations/subagent-runner.ts +8 -5
  26. package/examples/observme.yaml +3 -2
  27. package/package.json +14 -4
  28. package/src/commands/obs-agents.ts +17 -12
  29. package/src/commands/obs-backfill.ts +2 -2
  30. package/src/commands/obs-command-support.ts +2 -1
  31. package/src/commands/obs-cost.ts +15 -7
  32. package/src/commands/obs-health.ts +22 -2
  33. package/src/commands/obs-loki-summary.ts +4 -8
  34. package/src/commands/obs-session.ts +35 -28
  35. package/src/commands/obs-status.ts +56 -13
  36. package/src/commands/obs-tools.ts +19 -8
  37. package/src/commands/obs-trace.ts +9 -0
  38. package/src/commands/obs.ts +6 -1
  39. package/src/config/bootstrap-project-config.ts +50 -32
  40. package/src/config/defaults.ts +1 -2
  41. package/src/config/load-config.ts +270 -81
  42. package/src/config/project-paths.ts +318 -6
  43. package/src/config/query-limits.ts +10 -0
  44. package/src/config/schema.ts +18 -8
  45. package/src/config/transport-security.ts +107 -0
  46. package/src/config/validate.ts +88 -12
  47. package/src/extension.ts +2 -12
  48. package/src/integration.ts +6 -2
  49. package/src/otel/logs.ts +24 -13
  50. package/src/otel/metrics.ts +24 -13
  51. package/src/otel/otlp-endpoint.ts +41 -2
  52. package/src/otel/otlp-http-options.ts +11 -0
  53. package/src/otel/sdk.ts +155 -9
  54. package/src/otel/shutdown.ts +39 -11
  55. package/src/otel/traces.ts +34 -16
  56. package/src/pi/active-agent-lease.ts +101 -0
  57. package/src/pi/agent-tree-tracker.ts +14 -1
  58. package/src/pi/compatibility.ts +121 -0
  59. package/src/pi/event-handlers/agent-turn.ts +16 -9
  60. package/src/pi/event-handlers/lifecycle.ts +312 -106
  61. package/src/pi/event-handlers/llm.ts +17 -9
  62. package/src/pi/event-handlers/session-events.ts +53 -19
  63. package/src/pi/event-handlers/tool-bash.ts +21 -27
  64. package/src/pi/handler-internals.ts +16 -26
  65. package/src/pi/handler-runtime.ts +159 -54
  66. package/src/pi/handler-types.ts +40 -17
  67. package/src/pi/handlers.ts +12 -1
  68. package/src/pi/integration-api.ts +27 -15
  69. package/src/pi/session-correlation.ts +167 -0
  70. package/src/pi/subagent-spawn.ts +164 -31
  71. package/src/privacy/redact.ts +574 -68
  72. package/src/privacy/secret-patterns.ts +6 -1
  73. package/src/query/grafana-readiness.ts +18 -2
  74. package/src/query/grafana-transport.ts +150 -27
  75. package/src/query/grafana-url.ts +36 -0
  76. package/src/query/grafana.ts +4 -136
  77. package/src/query/loki.ts +2 -4
  78. package/src/query/prometheus.ts +8 -10
  79. package/src/query/tempo.ts +2 -4
  80. package/src/query/trace-link.ts +186 -0
  81. package/src/safety/display-bounds.ts +84 -0
  82. package/src/semconv/metrics.ts +7 -0
  83. 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;
@@ -8,8 +9,11 @@ export type ObservMeEnvironment = (typeof observMeEnvironments)[number];
8
9
  export type OtlpProtocol = "http/protobuf";
9
10
  export type PrivacyPathMode = (typeof privacyPathModes)[number];
10
11
 
12
+ export const ACTIVE_AGENT_LEASE_DURATION_MILLIS_MINIMUM = 10_000;
13
+ export const ACTIVE_AGENT_LEASE_DURATION_MILLIS_MAXIMUM = 300_000;
14
+ export const ACTIVE_AGENT_LEASE_EXPORT_SAFETY_MARGIN_MILLIS = 5_000;
15
+
11
16
  export interface OtlpTlsConfig {
12
- enabled: boolean;
13
17
  insecureSkipVerify: boolean;
14
18
  }
15
19
 
@@ -71,6 +75,7 @@ export interface MetricsConfig {
71
75
  enabled: boolean;
72
76
  exportIntervalMillis: number;
73
77
  exportTimeoutMillis: number;
78
+ activeAgentLeaseDurationMillis: number;
74
79
  labels?: string[];
75
80
  }
76
81
 
@@ -173,7 +178,6 @@ export interface ObservMeConfig {
173
178
  enabled: boolean;
174
179
  environment: ObservMeEnvironment;
175
180
  tenant: string;
176
- replayOnStart: boolean;
177
181
  otlp: OtlpConfig;
178
182
  resource: ResourceConfig;
179
183
  workflow: WorkflowConfig;
@@ -192,6 +196,10 @@ const environmentSchema = StringEnum(observMeEnvironments);
192
196
  const otlpProtocolSchema = Type.Literal("http/protobuf");
193
197
  const stringRecordSchema = Type.Record(Type.String(), Type.String());
194
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
+ });
195
203
  const ratioSchema = Type.Number({ minimum: 0, maximum: 1 });
196
204
  const pathModeSchema = StringEnum(privacyPathModes);
197
205
 
@@ -208,7 +216,6 @@ export const observMeConfigSchema = Type.Object(
208
216
  enabled: Type.Boolean(),
209
217
  environment: environmentSchema,
210
218
  tenant: Type.String({ minLength: 1 }),
211
- replayOnStart: Type.Boolean(),
212
219
  otlp: Type.Object(
213
220
  {
214
221
  endpoint: Type.String({ minLength: 1 }),
@@ -217,7 +224,6 @@ export const observMeConfigSchema = Type.Object(
217
224
  headers: stringRecordSchema,
218
225
  tls: Type.Object(
219
226
  {
220
- enabled: Type.Boolean(),
221
227
  insecureSkipVerify: Type.Boolean(),
222
228
  },
223
229
  { additionalProperties: false },
@@ -288,6 +294,10 @@ export const observMeConfigSchema = Type.Object(
288
294
  enabled: Type.Boolean(),
289
295
  exportIntervalMillis: positiveIntegerSchema,
290
296
  exportTimeoutMillis: positiveIntegerSchema,
297
+ activeAgentLeaseDurationMillis: Type.Integer({
298
+ minimum: ACTIVE_AGENT_LEASE_DURATION_MILLIS_MINIMUM,
299
+ maximum: ACTIVE_AGENT_LEASE_DURATION_MILLIS_MAXIMUM,
300
+ }),
291
301
  labels: Type.Optional(Type.Array(Type.String({ minLength: 1 }))),
292
302
  },
293
303
  { additionalProperties: false },
@@ -352,10 +362,10 @@ export const observMeConfigSchema = Type.Object(
352
362
  {
353
363
  enabled: Type.Boolean(),
354
364
  timeoutMs: positiveIntegerSchema,
355
- maxLogs: positiveIntegerSchema,
356
- maxTraces: positiveIntegerSchema,
357
- maxMetricSeries: positiveIntegerSchema,
358
- maxAgents: positiveIntegerSchema,
365
+ maxLogs: queryResultCountSchema,
366
+ maxTraces: queryResultCountSchema,
367
+ maxMetricSeries: queryResultCountSchema,
368
+ maxAgents: queryResultCountSchema,
359
369
  links: Type.Object(
360
370
  {
361
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
+ }