@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,456 @@
1
+ import { Compile } from "typebox/compile";
2
+ import { defaultObservMeConfig } from "./defaults.ts";
3
+ import { observMeConfigSchema } from "./schema.ts";
4
+ import type { ObservMeConfig } from "./schema.ts";
5
+ import { validateCustomRedactionPatterns } from "../privacy/redact.ts";
6
+
7
+ export interface ValidationIssue {
8
+ code: string;
9
+ message: string;
10
+ }
11
+
12
+ export interface ConfigValidationOptions {
13
+ env?: NodeJS.ProcessEnv;
14
+ isProjectTrusted?: boolean;
15
+ projectConfigWasRead?: boolean;
16
+ }
17
+
18
+ export interface ConfigValidationResult {
19
+ valid: boolean;
20
+ issues: ValidationIssue[];
21
+ }
22
+
23
+ export interface ConfigRejectionDiagnostic {
24
+ readonly issueCodes: readonly string[];
25
+ readonly issueCount: number;
26
+ }
27
+
28
+ export interface EnsuredObservMeConfig {
29
+ readonly config: ObservMeConfig;
30
+ readonly rejection?: ConfigRejectionDiagnostic;
31
+ }
32
+
33
+ export interface ConfigLogSink {
34
+ warn?: (message: string) => void;
35
+ }
36
+
37
+ export interface UnsafeCaptureWarningContext {
38
+ ui?: {
39
+ notify?: (message: string, level?: "warning" | "info" | "error") => void | Promise<void>;
40
+ };
41
+ }
42
+
43
+ const forbiddenMetricLabelPattern =
44
+ /(?:^|[._-])(?:(?:workflow|session|agent|parent|child|trace|span|entry|spawn|tool_call)(?:[._-]|$)|id$)/i;
45
+ const lineageValuePattern = /^[A-Za-z0-9._:-]+$/;
46
+ const traceIdPattern = /^[a-f0-9]{32}$/i;
47
+ const spanIdPattern = /^[a-f0-9]{16}$/i;
48
+ const maximumLineageValueLength = 128;
49
+ const maximumQueueSize = 10_000;
50
+ const maximumActiveRegistrySize = 100_000;
51
+ const maximumStructuralIssueDetails = 5;
52
+ const maximumConfigDiagnosticCodeInputs = 64;
53
+ const maximumConfigDiagnosticIssueCount = 1_000_000;
54
+ const unknownConfigValidationIssueCode = "unknown_config_validation_issue";
55
+ const knownConfigValidationIssueCodes = new Set([
56
+ "invalid_config_shape",
57
+ "unsafe_capture_without_redaction",
58
+ "insecure_production_transport",
59
+ "invalid_signal_endpoint_path",
60
+ "high_cardinality_metric_label",
61
+ "custom_redaction_pattern_limit",
62
+ "custom_redaction_pattern_too_long",
63
+ "custom_redaction_pattern_unsupported_construct",
64
+ "custom_redaction_pattern_nested_quantifier",
65
+ "custom_redaction_pattern_empty_match",
66
+ "invalid_custom_redaction_pattern",
67
+ "untrusted_project_config_read",
68
+ "malformed_lineage_value",
69
+ "queue_size_exceeds_guardrail",
70
+ ]);
71
+ const observMeConfigValidator = Compile(observMeConfigSchema);
72
+
73
+ export function validateObservMeConfig(
74
+ config: ObservMeConfig,
75
+ options: ConfigValidationOptions = {},
76
+ ): ConfigValidationResult {
77
+ const structuralIssues = validateConfigStructure(config);
78
+ if (structuralIssues.length > 0) return { valid: false, issues: structuralIssues };
79
+
80
+ const issues = [
81
+ ...validateRedactionBoundary(config),
82
+ ...validateTransportSecurity(config),
83
+ ...validateSignalEndpoints(config),
84
+ ...validateMetricLabels(config),
85
+ ...validateCustomRedactionPatternConfig(config),
86
+ ...validateProjectTrust(options),
87
+ ...validateLineageEnvironment(config, options.env ?? process.env),
88
+ ...validateQueueGuardrails(config),
89
+ ];
90
+
91
+ return { valid: issues.length === 0, issues };
92
+ }
93
+
94
+ export function ensureValidObservMeConfig(
95
+ config: ObservMeConfig,
96
+ options: ConfigValidationOptions & { logger?: ConfigLogSink } = {},
97
+ ): ObservMeConfig {
98
+ return ensureValidObservMeConfigWithDiagnostics(config, options).config;
99
+ }
100
+
101
+ export function ensureValidObservMeConfigWithDiagnostics(
102
+ config: ObservMeConfig,
103
+ options: ConfigValidationOptions & { logger?: ConfigLogSink } = {},
104
+ ): EnsuredObservMeConfig {
105
+ const result = validateObservMeConfig(config, options);
106
+ if (result.valid) return { config };
107
+
108
+ const rejection = createConfigRejectionDiagnostic(result.issues);
109
+ logValidationRejection(rejection, options.logger);
110
+ return { config: structuredClone(defaultObservMeConfig), rejection };
111
+ }
112
+
113
+ export function createConfigRejectionDiagnostic(issues: readonly ValidationIssue[]): ConfigRejectionDiagnostic {
114
+ return normalizeConfigRejectionDiagnostic({
115
+ issueCodes: issues.map(issue => issue.code),
116
+ issueCount: issues.length,
117
+ });
118
+ }
119
+
120
+ export function normalizeConfigRejectionDiagnostic(
121
+ rejection: ConfigRejectionDiagnostic,
122
+ ): ConfigRejectionDiagnostic {
123
+ const rawIssueCodes = Array.isArray(rejection.issueCodes)
124
+ ? rejection.issueCodes.slice(0, maximumConfigDiagnosticCodeInputs)
125
+ : [];
126
+ const issueCodes = new Set<string>();
127
+
128
+ for (const code of rawIssueCodes) {
129
+ issueCodes.add(normalizeConfigValidationIssueCode(code));
130
+ }
131
+
132
+ return {
133
+ issueCodes: [...issueCodes],
134
+ issueCount: normalizeConfigDiagnosticIssueCount(rejection.issueCount, issueCodes.size),
135
+ };
136
+ }
137
+
138
+ function normalizeConfigValidationIssueCode(code: unknown): string {
139
+ return typeof code === "string" && knownConfigValidationIssueCodes.has(code)
140
+ ? code
141
+ : unknownConfigValidationIssueCode;
142
+ }
143
+
144
+ function normalizeConfigDiagnosticIssueCount(value: number, minimum: number): number {
145
+ if (!Number.isFinite(value)) return minimum;
146
+ return Math.max(minimum, Math.min(Math.trunc(value), maximumConfigDiagnosticIssueCount));
147
+ }
148
+
149
+ export function hasContentCaptureEnabled(config: ObservMeConfig): boolean {
150
+ return Object.values(config.capture).some(Boolean);
151
+ }
152
+
153
+ export async function emitUnsafeCaptureWarning(
154
+ config: ObservMeConfig,
155
+ ctx: UnsafeCaptureWarningContext,
156
+ ): Promise<boolean> {
157
+ if (!config.privacy.allowUnsafeCapture || !hasContentCaptureEnabled(config)) return false;
158
+
159
+ await ctx.ui?.notify?.(unsafeCaptureWarningMessage(config), "warning");
160
+ return true;
161
+ }
162
+
163
+ function validateConfigStructure(config: unknown): ValidationIssue[] {
164
+ if (observMeConfigValidator.Check(config)) return [];
165
+
166
+ try {
167
+ return buildStructuralValidationIssues(observMeConfigValidator.Errors(config));
168
+ } catch (error) {
169
+ return buildUnreadableStructuralValidationIssues(error);
170
+ }
171
+ }
172
+
173
+ function buildUnreadableStructuralValidationIssues(error: unknown): ValidationIssue[] {
174
+ return [
175
+ {
176
+ code: "invalid_config_shape",
177
+ message: `Configuration shape is invalid and could not be inspected safely (${formatStructuralInspectionFailure(error)}).`,
178
+ },
179
+ ];
180
+ }
181
+
182
+ function formatStructuralInspectionFailure(error: unknown): string {
183
+ if (error instanceof Error) return "schema validator threw an Error";
184
+ if (typeof error === "string") return "schema validator threw a string";
185
+ return "schema validator threw a non-error value";
186
+ }
187
+
188
+ function buildStructuralValidationIssues(errors: Array<{ keyword: string; instancePath: string }>): ValidationIssue[] {
189
+ const visibleErrors = errors.slice(0, maximumStructuralIssueDetails);
190
+ const issues = visibleErrors.map(error => ({
191
+ code: "invalid_config_shape",
192
+ message: formatStructuralValidationMessage(error),
193
+ }));
194
+
195
+ if (errors.length > visibleErrors.length) {
196
+ issues.push({
197
+ code: "invalid_config_shape",
198
+ message: `Configuration shape has ${errors.length - visibleErrors.length} additional structural issue(s).`,
199
+ });
200
+ }
201
+
202
+ return issues;
203
+ }
204
+
205
+ function formatStructuralValidationMessage(error: { keyword: string; instancePath: string }): string {
206
+ return `Configuration shape is invalid at ${formatSchemaPath(error.instancePath)}: ${describeSchemaKeyword(error.keyword)}.`;
207
+ }
208
+
209
+ function formatSchemaPath(instancePath: string): string {
210
+ if (!instancePath) return "root";
211
+ return instancePath.replaceAll("~1", "/").replaceAll("~0", "~");
212
+ }
213
+
214
+ function describeSchemaKeyword(keyword: string): string {
215
+ if (keyword === "additionalProperties") return "unknown property is not allowed";
216
+ if (keyword === "required") return "required property is missing";
217
+ if (keyword === "type") return "value has an unsupported type";
218
+ if (keyword === "const" || keyword === "anyOf" || keyword === "enum") return "value is not one of the supported options";
219
+ if (keyword === "minimum" || keyword === "maximum") return "numeric value is outside the allowed range";
220
+ if (keyword === "minLength" || keyword === "maxLength") return "string length is outside the allowed range";
221
+ return `schema rule ${keyword} failed`;
222
+ }
223
+
224
+ function unsafeCaptureWarningMessage(config: ObservMeConfig): string {
225
+ if (config.privacy.redactionEnabled) {
226
+ return "ObservMe unsafe capture is active. Prompt, response, tool, bash, or path content may be exported after configured redaction.";
227
+ }
228
+
229
+ return "ObservMe unsafe capture is active with redaction disabled. Unredacted sensitive prompt, response, tool, bash, or path content may be exported.";
230
+ }
231
+
232
+ function validateRedactionBoundary(config: ObservMeConfig): ValidationIssue[] {
233
+ if (config.privacy.allowUnsafeCapture || config.privacy.redactionEnabled || !hasContentCaptureEnabled(config)) return [];
234
+
235
+ return [
236
+ {
237
+ code: "unsafe_capture_without_redaction",
238
+ message: "Content capture requires redaction unless privacy.allowUnsafeCapture is true.",
239
+ },
240
+ ];
241
+ }
242
+
243
+ function validateTransportSecurity(config: ObservMeConfig): ValidationIssue[] {
244
+ if (config.environment !== "production" || config.privacy.allowInsecureTransport) return [];
245
+
246
+ return [
247
+ ...validateProductionHttpEndpoint("otlp.endpoint", config.otlp.endpoint),
248
+ ...validateProductionHttpEndpoint("otlp.signalEndpoints.traces", config.otlp.signalEndpoints?.traces),
249
+ ...validateProductionHttpEndpoint("otlp.signalEndpoints.metrics", config.otlp.signalEndpoints?.metrics),
250
+ ...validateProductionHttpEndpoint("otlp.signalEndpoints.logs", config.otlp.signalEndpoints?.logs),
251
+ ...validateProductionHttpEndpoint("query.grafana.url", config.query.grafana.url),
252
+ ];
253
+ }
254
+
255
+ function validateProductionHttpEndpoint(name: string, endpoint: string | undefined): ValidationIssue[] {
256
+ if (!endpoint || !isHttpEndpoint(endpoint)) return [];
257
+
258
+ return [
259
+ {
260
+ code: "insecure_production_transport",
261
+ message: `${name} must not use http:// in production unless privacy.allowInsecureTransport is true.`,
262
+ },
263
+ ];
264
+ }
265
+
266
+ function isHttpEndpoint(endpoint: string): boolean {
267
+ return endpoint.trim().toLowerCase().startsWith("http://");
268
+ }
269
+
270
+ function validateSignalEndpoints(config: ObservMeConfig): ValidationIssue[] {
271
+ if (config.otlp.protocol !== "http/protobuf") return [];
272
+
273
+ const endpoints = config.otlp.signalEndpoints;
274
+ if (!endpoints) return [];
275
+
276
+ return [
277
+ ...validateSignalEndpoint("traces", endpoints.traces, "/v1/traces"),
278
+ ...validateSignalEndpoint("metrics", endpoints.metrics, "/v1/metrics"),
279
+ ...validateSignalEndpoint("logs", endpoints.logs, "/v1/logs"),
280
+ ];
281
+ }
282
+
283
+ function validateSignalEndpoint(signal: string, endpoint: string | undefined, requiredPath: string): ValidationIssue[] {
284
+ if (!endpoint || endpointPathMatches(endpoint, requiredPath)) return [];
285
+
286
+ return [
287
+ {
288
+ code: "invalid_signal_endpoint_path",
289
+ message: `${signal} OTLP HTTP exporter URL must include ${requiredPath}.`,
290
+ },
291
+ ];
292
+ }
293
+
294
+ function endpointPathMatches(endpoint: string, requiredPath: string): boolean {
295
+ try {
296
+ return new URL(endpoint).pathname.endsWith(requiredPath);
297
+ } catch {
298
+ return false;
299
+ }
300
+ }
301
+
302
+ function validateMetricLabels(config: ObservMeConfig): ValidationIssue[] {
303
+ const labels = config.metrics.labels ?? [];
304
+ return labels.filter(isForbiddenMetricLabel).map(label => ({
305
+ code: "high_cardinality_metric_label",
306
+ message: `Metric label ${label} is a forbidden high-cardinality identifier.`,
307
+ }));
308
+ }
309
+
310
+ function validateCustomRedactionPatternConfig(config: ObservMeConfig): ValidationIssue[] {
311
+ return validateCustomRedactionPatterns(config.privacy.customRedactionPatterns).map(issue => ({
312
+ code: issue.code,
313
+ message: issue.message,
314
+ }));
315
+ }
316
+
317
+ function isForbiddenMetricLabel(label: string): boolean {
318
+ return forbiddenMetricLabelPattern.test(label);
319
+ }
320
+
321
+ function validateProjectTrust(options: ConfigValidationOptions): ValidationIssue[] {
322
+ if (!options.projectConfigWasRead || options.isProjectTrusted !== false) return [];
323
+
324
+ return [
325
+ {
326
+ code: "untrusted_project_config_read",
327
+ message: "Project-local ObservMe config must not be read while ctx.isProjectTrusted() is false.",
328
+ },
329
+ ];
330
+ }
331
+
332
+ function validateLineageEnvironment(config: ObservMeConfig, env: NodeJS.ProcessEnv): ValidationIssue[] {
333
+ const allEnvironmentNames = configuredLineageEnvironmentNames(config);
334
+ const environmentNameIssues = validateLineageEnvironmentNames(allEnvironmentNames);
335
+ if (environmentNameIssues.length > 0) return environmentNameIssues;
336
+
337
+ const lineageEnvNames = [
338
+ config.workflow.idEnv,
339
+ config.agent.idEnv,
340
+ config.agent.parentIdEnv,
341
+ config.agent.rootIdEnv,
342
+ config.agent.parentSessionIdEnv,
343
+ config.agent.spawnIdEnv,
344
+ config.agent.capabilityEnv,
345
+ ];
346
+ const issues = lineageEnvNames.flatMap(name => validateLineageValue(name, env[name]));
347
+
348
+ return [
349
+ ...issues,
350
+ ...validateTraceValue(config.agent.parentTraceIdEnv, env[config.agent.parentTraceIdEnv]),
351
+ ...validateSpanValue(config.agent.parentSpanIdEnv, env[config.agent.parentSpanIdEnv]),
352
+ ...validateDepthValue(config.agent.depthEnv, env[config.agent.depthEnv]),
353
+ ];
354
+ }
355
+
356
+ function configuredLineageEnvironmentNames(config: ObservMeConfig): string[] {
357
+ return [
358
+ config.workflow.idEnv,
359
+ config.agent.idEnv,
360
+ config.agent.parentIdEnv,
361
+ config.agent.rootIdEnv,
362
+ config.agent.parentSessionIdEnv,
363
+ config.agent.parentTraceIdEnv,
364
+ config.agent.parentSpanIdEnv,
365
+ config.agent.depthEnv,
366
+ config.agent.spawnIdEnv,
367
+ config.agent.capabilityEnv,
368
+ ];
369
+ }
370
+
371
+ function validateLineageEnvironmentNames(names: string[]): ValidationIssue[] {
372
+ const normalizedNames = names.map(name => name.toUpperCase());
373
+ const uniqueNames = new Set(normalizedNames);
374
+ const malformed = names.some(name => !/^[A-Za-z_]\w{0,127}$/u.test(name));
375
+ const reserved = normalizedNames.some(name => name === "TRACEPARENT" || name === "TRACESTATE");
376
+ if (!malformed && !reserved && uniqueNames.size === names.length) return [];
377
+
378
+ return [
379
+ {
380
+ code: "malformed_lineage_value",
381
+ message: "Configured lineage environment variable names must be unique, safe, and distinct from W3C trace headers.",
382
+ },
383
+ ];
384
+ }
385
+
386
+ function validateLineageValue(envName: string, value: string | undefined): ValidationIssue[] {
387
+ if (!value) return [];
388
+ if (value.length <= maximumLineageValueLength && lineageValuePattern.test(value)) return [];
389
+
390
+ return [
391
+ {
392
+ code: "malformed_lineage_value",
393
+ message: `${envName} contains a malformed, oversized, or unsafe lineage value.`,
394
+ },
395
+ ];
396
+ }
397
+
398
+ function validateTraceValue(envName: string, value: string | undefined): ValidationIssue[] {
399
+ if (!value || (traceIdPattern.test(value) && !/^0{32}$/u.test(value))) return [];
400
+ return [{ code: "malformed_lineage_value", message: `${envName} must be a non-zero 32-character hex trace id.` }];
401
+ }
402
+
403
+ function validateSpanValue(envName: string, value: string | undefined): ValidationIssue[] {
404
+ if (!value || (spanIdPattern.test(value) && !/^0{16}$/u.test(value))) return [];
405
+ return [{ code: "malformed_lineage_value", message: `${envName} must be a non-zero 16-character hex span id.` }];
406
+ }
407
+
408
+ function validateDepthValue(envName: string, value: string | undefined): ValidationIssue[] {
409
+ if (!value) return [];
410
+ const parsed = Number(value);
411
+ if (Number.isInteger(parsed) && parsed >= 0 && parsed <= 64) return [];
412
+ return [{ code: "malformed_lineage_value", message: `${envName} must be an integer depth between 0 and 64.` }];
413
+ }
414
+
415
+ function validateQueueGuardrails(config: ObservMeConfig): ValidationIssue[] {
416
+ const queueChecks = [
417
+ ["traces.batch.maxQueueSize", config.traces.batch.maxQueueSize, maximumQueueSize],
418
+ ["logs.batch.maxQueueSize", config.logs.batch.maxQueueSize, maximumQueueSize],
419
+ ["limits.maxActiveAgentRuns", config.limits.maxActiveAgentRuns, maximumActiveRegistrySize],
420
+ ["limits.maxActiveTurns", config.limits.maxActiveTurns, maximumActiveRegistrySize],
421
+ ["limits.maxActiveToolCalls", config.limits.maxActiveToolCalls, maximumActiveRegistrySize],
422
+ ["limits.maxActiveLlmRequests", config.limits.maxActiveLlmRequests, maximumActiveRegistrySize],
423
+ ["limits.maxActiveSubagentSpawns", config.limits.maxActiveSubagentSpawns, maximumActiveRegistrySize],
424
+ ["limits.maxActiveAgentWaits", config.limits.maxActiveAgentWaits, maximumActiveRegistrySize],
425
+ ["limits.maxActiveAgentJoins", config.limits.maxActiveAgentJoins, maximumActiveRegistrySize],
426
+ ] as const;
427
+ const oversizeIssues = queueChecks.flatMap(([name, value, maximum]) => validateMaximumSize(name, value, maximum));
428
+
429
+ return [
430
+ ...oversizeIssues,
431
+ ...validateBatchSize("traces.batch.maxExportBatchSize", config.traces.batch.maxExportBatchSize, config.traces.batch.maxQueueSize),
432
+ ...validateBatchSize("logs.batch.maxExportBatchSize", config.logs.batch.maxExportBatchSize, config.logs.batch.maxQueueSize),
433
+ ];
434
+ }
435
+
436
+ function validateMaximumSize(name: string, value: number, maximum: number): ValidationIssue[] {
437
+ if (value <= maximum) return [];
438
+ return [{ code: "queue_size_exceeds_guardrail", message: `${name} exceeds memory guardrail ${maximum}.` }];
439
+ }
440
+
441
+ function validateBatchSize(name: string, value: number, queueSize: number): ValidationIssue[] {
442
+ if (value <= queueSize) return [];
443
+ return [{ code: "queue_size_exceeds_guardrail", message: `${name} must not exceed its maxQueueSize.` }];
444
+ }
445
+
446
+ function logValidationRejection(rejection: ConfigRejectionDiagnostic, logger: ConfigLogSink | undefined): void {
447
+ if (!logger?.warn) return;
448
+
449
+ try {
450
+ logger.warn(
451
+ `ObservMe config rejected (${rejection.issueCodes.join(", ")}); ${rejection.issueCount} issue(s), safe defaults applied.`,
452
+ );
453
+ } catch {
454
+ return;
455
+ }
456
+ }
@@ -0,0 +1,4 @@
1
+ // Branding constants for the ObservMe Pi extension.
2
+ export const EXTENSION_DISPLAY_NAME = "ObservMe";
3
+ export const EXTENSION_STATUS_KEY = "observme";
4
+ export const EXTENSION_STATUS_VALUE = "🧿";
@@ -0,0 +1,6 @@
1
+ export {
2
+ readDiagnosticMessage,
3
+ sanitizeUiDiagnosticText as sanitizeDiagnosticText,
4
+ } from "../safety/sensitive-input.ts";
5
+
6
+ export type { DiagnosticReplacement } from "../safety/sensitive-input.ts";
@@ -0,0 +1,38 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { registerObsCommand } from "./commands/obs.ts";
3
+ import { registerHandlers } from "./pi/handlers.ts";
4
+
5
+ const extensionPiApiCompatibilityErrorMessage =
6
+ "ObservMe/Pi API compatibility error: expected Pi ExtensionAPI with on(eventName, handler) and registerCommand(name, options) before registering ObservMe.";
7
+ const partialInitializationErrorMessage =
8
+ "ObservMe extension initialization failed while registering /obs after Pi event handlers were already registered. Pi ExtensionAPI does not expose unregister hooks for event handlers or slash commands, so ObservMe cannot roll back partial registration; restart Pi after fixing command registration.";
9
+
10
+ export default function observme(pi: ExtensionAPI): void {
11
+ assertRegistrationApiAvailable(pi);
12
+ // Only the Pi process environment is eligible for launcher-provided lineage.
13
+ // Session config loading keeps trusted project .env values out of this boundary.
14
+ registerHandlers(pi, { trustedParentContext: true });
15
+ registerObsCommandWithPartialInitializationDiagnostic(pi);
16
+ }
17
+
18
+ function assertRegistrationApiAvailable(pi: ExtensionAPI): void {
19
+ const api = pi as Partial<ExtensionAPI> | null | undefined;
20
+
21
+ if (!api || typeof api.on !== "function" || typeof api.registerCommand !== "function") {
22
+ throw new TypeError(extensionPiApiCompatibilityErrorMessage);
23
+ }
24
+ }
25
+
26
+ function registerObsCommandWithPartialInitializationDiagnostic(pi: ExtensionAPI): void {
27
+ try {
28
+ registerObsCommand(pi);
29
+ } catch (error) {
30
+ throw createPartialInitializationError(error);
31
+ }
32
+ }
33
+
34
+ function createPartialInitializationError(cause: unknown): Error {
35
+ // Pi exposes pi.on/registerCommand but no unregister API for those registrations.
36
+ // If command registration fails after handler registration, ObservMe can only report the partial state.
37
+ return new Error(partialInitializationErrorMessage, { cause });
38
+ }
@@ -0,0 +1,160 @@
1
+ import type { Logger } from "@opentelemetry/api-logs";
2
+ import { createNoopLogger, logs } from "@opentelemetry/api-logs";
3
+ import type { Resource } from "@opentelemetry/resources";
4
+ import { resourceFromAttributes } from "@opentelemetry/resources";
5
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
6
+ import type { LogRecordExporter, LogRecordProcessor } from "@opentelemetry/sdk-logs";
7
+ import { BatchLogRecordProcessor, LoggerProvider } from "@opentelemetry/sdk-logs";
8
+ import type { LogsBatchConfig, ObservMeConfig } from "../config/schema.ts";
9
+ import { appendOtlpSignalPath } from "./otlp-endpoint.ts";
10
+ import type { StartOtelSdkFactoryOptions } from "./sdk.ts";
11
+
12
+ export const OBSERVME_LOGGER_NAME = "@senad-d/observme";
13
+ export const OTLP_LOG_SIGNAL_PATH = "/v1/logs";
14
+
15
+ export const DOCUMENTED_LOG_BATCH_DEFAULTS = {
16
+ maxQueueSize: 2048,
17
+ maxExportBatchSize: 512,
18
+ scheduledDelayMillis: 1000,
19
+ } satisfies LogsBatchConfig;
20
+
21
+ export type LogPipelineState = "idle" | "disabled" | "started" | "shutdown";
22
+
23
+ export interface OtlpLogExporterOptions {
24
+ readonly url: string;
25
+ readonly headers: Record<string, string>;
26
+ readonly timeoutMillis: number;
27
+ }
28
+
29
+ export interface LogProviderOptions {
30
+ readonly resource: Resource;
31
+ readonly processors: LogRecordProcessor[];
32
+ readonly forceFlushTimeoutMillis: number;
33
+ }
34
+
35
+ export interface LogProviderLike {
36
+ getLogger: (name: string) => Logger;
37
+ forceFlush?: () => Promise<void> | void;
38
+ shutdown?: () => Promise<void> | void;
39
+ }
40
+
41
+ export type LogExporterFactory = (options: OtlpLogExporterOptions) => LogRecordExporter;
42
+ export type LogRecordProcessorFactory = (exporter: LogRecordExporter, options: LogsBatchConfig) => LogRecordProcessor;
43
+ export type LogProviderFactory = (options: LogProviderOptions) => LogProviderLike;
44
+
45
+ export interface ObservMeLogSdkOptions {
46
+ readonly config: ObservMeConfig;
47
+ readonly loggerName?: string;
48
+ readonly registerGlobal?: boolean;
49
+ readonly exporterFactory?: LogExporterFactory;
50
+ readonly processorFactory?: LogRecordProcessorFactory;
51
+ readonly loggerProviderFactory?: LogProviderFactory;
52
+ }
53
+
54
+ export interface LogExporterWiring {
55
+ readonly enabled: boolean;
56
+ readonly exporter: OtlpLogExporterOptions;
57
+ readonly batch: LogsBatchConfig;
58
+ }
59
+
60
+ const noopLogger = createNoopLogger();
61
+
62
+ export class ObservMeLogSdk {
63
+ readonly #config: ObservMeConfig;
64
+ readonly #loggerName: string;
65
+ readonly #registerGlobal: boolean;
66
+ readonly #exporterFactory: LogExporterFactory;
67
+ readonly #processorFactory: LogRecordProcessorFactory;
68
+ readonly #loggerProviderFactory: LogProviderFactory;
69
+ #provider?: LogProviderLike;
70
+ #logger: Logger = noopLogger;
71
+ #state: LogPipelineState = "idle";
72
+
73
+ constructor(options: ObservMeLogSdkOptions) {
74
+ this.#config = options.config;
75
+ this.#loggerName = options.loggerName ?? OBSERVME_LOGGER_NAME;
76
+ this.#registerGlobal = options.registerGlobal ?? true;
77
+ this.#exporterFactory = options.exporterFactory ?? createOtlpLogExporter;
78
+ this.#processorFactory = options.processorFactory ?? createBatchLogRecordProcessor;
79
+ this.#loggerProviderFactory = options.loggerProviderFactory ?? createLogProvider;
80
+ }
81
+
82
+ get state(): LogPipelineState {
83
+ return this.#state;
84
+ }
85
+
86
+ get logger(): Logger {
87
+ return this.#logger;
88
+ }
89
+
90
+ start(): void {
91
+ if (this.#state === "started" || this.#state === "disabled") return;
92
+ if (!this.#config.logs.enabled) {
93
+ this.#state = "disabled";
94
+ return;
95
+ }
96
+
97
+ const wiring = buildLogExporterWiring(this.#config);
98
+ const exporter = this.#exporterFactory(wiring.exporter);
99
+ const processor = this.#processorFactory(exporter, wiring.batch);
100
+ this.#provider = this.#loggerProviderFactory({
101
+ resource: createLogResource(this.#config),
102
+ processors: [processor],
103
+ forceFlushTimeoutMillis: this.#config.shutdown.flushTimeoutMs,
104
+ });
105
+
106
+ if (this.#registerGlobal) logs.setGlobalLoggerProvider(this.#provider);
107
+ this.#logger = this.#provider.getLogger(this.#loggerName);
108
+ this.#state = "started";
109
+ }
110
+
111
+ async forceFlush(): Promise<void> {
112
+ await this.#provider?.forceFlush?.();
113
+ }
114
+
115
+ async shutdown(): Promise<void> {
116
+ await this.#provider?.shutdown?.();
117
+ this.#state = "shutdown";
118
+ this.#logger = noopLogger;
119
+ }
120
+ }
121
+
122
+ export function createLogSessionScopedOtelSdk(options: StartOtelSdkFactoryOptions): ObservMeLogSdk {
123
+ return new ObservMeLogSdk({ config: options.config });
124
+ }
125
+
126
+ export function buildLogExporterWiring(config: ObservMeConfig): LogExporterWiring {
127
+ return {
128
+ enabled: config.logs.enabled,
129
+ exporter: buildOtlpLogExporterOptions(config),
130
+ batch: { ...config.logs.batch },
131
+ };
132
+ }
133
+
134
+ export function buildOtlpLogExporterOptions(config: ObservMeConfig): OtlpLogExporterOptions {
135
+ return {
136
+ url: resolveLogEndpoint(config),
137
+ headers: { ...config.otlp.headers },
138
+ timeoutMillis: config.otlp.timeoutMs,
139
+ };
140
+ }
141
+
142
+ export function resolveLogEndpoint(config: ObservMeConfig): string {
143
+ return config.otlp.signalEndpoints?.logs ?? appendOtlpSignalPath(config.otlp.endpoint, OTLP_LOG_SIGNAL_PATH);
144
+ }
145
+
146
+ function createOtlpLogExporter(options: OtlpLogExporterOptions): LogRecordExporter {
147
+ return new OTLPLogExporter(options);
148
+ }
149
+
150
+ function createBatchLogRecordProcessor(exporter: LogRecordExporter, options: LogsBatchConfig): LogRecordProcessor {
151
+ return new BatchLogRecordProcessor({ exporter, ...options });
152
+ }
153
+
154
+ function createLogProvider(options: LogProviderOptions): LogProviderLike {
155
+ return new LoggerProvider(options);
156
+ }
157
+
158
+ function createLogResource(config: ObservMeConfig): Resource {
159
+ return resourceFromAttributes(config.resource.attributes);
160
+ }