@senad-d/observme 0.1.4 → 0.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/README.md +24 -8
- package/docs/agent-subagent-observability-requirements.md +4 -3
- package/docs/compatibility-matrix.md +11 -2
- package/docs/configuration.md +2 -2
- package/docs/extension-integration.md +20 -13
- package/docs/reference/03-pi-event-and-session-model.md +6 -6
- package/docs/reference/04-telemetry-semantic-conventions.md +1 -0
- package/docs/reference/06-security-privacy-redaction.md +13 -6
- package/docs/reference/08-query-grafana-integration.md +30 -7
- package/docs/reference/11-deployment-runbooks.md +21 -0
- package/docs/reference/12-configuration-reference.md +30 -4
- package/examples/integrations/subagent-runner.ts +8 -5
- package/examples/observme.yaml +2 -2
- package/package.json +12 -4
- package/src/commands/obs-agents.ts +17 -12
- package/src/commands/obs-backfill.ts +2 -2
- package/src/commands/obs-command-support.ts +2 -1
- package/src/commands/obs-cost.ts +15 -7
- package/src/commands/obs-health.ts +22 -2
- package/src/commands/obs-loki-summary.ts +4 -8
- package/src/commands/obs-session.ts +35 -28
- package/src/commands/obs-status.ts +56 -13
- package/src/commands/obs-tools.ts +19 -8
- package/src/commands/obs-trace.ts +9 -0
- package/src/commands/obs.ts +6 -1
- package/src/config/bootstrap-project-config.ts +49 -32
- package/src/config/defaults.ts +0 -2
- package/src/config/load-config.ts +270 -92
- package/src/config/project-paths.ts +318 -6
- package/src/config/query-limits.ts +10 -0
- package/src/config/schema.ts +9 -8
- package/src/config/transport-security.ts +107 -0
- package/src/config/validate.ts +68 -11
- package/src/extension.ts +2 -12
- package/src/integration.ts +6 -2
- package/src/otel/logs.ts +24 -13
- package/src/otel/metrics.ts +24 -13
- package/src/otel/otlp-endpoint.ts +41 -2
- package/src/otel/otlp-http-options.ts +11 -0
- package/src/otel/sdk.ts +155 -9
- package/src/otel/shutdown.ts +39 -11
- package/src/otel/traces.ts +34 -16
- package/src/pi/agent-tree-tracker.ts +14 -1
- package/src/pi/compatibility.ts +121 -0
- package/src/pi/event-handlers/agent-turn.ts +16 -9
- package/src/pi/event-handlers/lifecycle.ts +207 -75
- package/src/pi/event-handlers/llm.ts +17 -9
- package/src/pi/event-handlers/session-events.ts +53 -19
- package/src/pi/event-handlers/tool-bash.ts +21 -27
- package/src/pi/handler-internals.ts +16 -26
- package/src/pi/handler-runtime.ts +138 -51
- package/src/pi/handler-types.ts +30 -15
- package/src/pi/handlers.ts +12 -1
- package/src/pi/integration-api.ts +27 -15
- package/src/pi/session-correlation.ts +167 -0
- package/src/pi/subagent-spawn.ts +164 -31
- package/src/privacy/redact.ts +574 -68
- package/src/privacy/secret-patterns.ts +6 -1
- package/src/query/grafana-readiness.ts +18 -2
- package/src/query/grafana-transport.ts +150 -27
- package/src/query/grafana-url.ts +36 -0
- package/src/query/grafana.ts +4 -136
- package/src/query/loki.ts +2 -4
- package/src/query/prometheus.ts +8 -10
- package/src/query/tempo.ts +2 -4
- package/src/query/trace-link.ts +186 -0
- package/src/safety/display-bounds.ts +84 -0
- package/src/semconv/metrics.ts +1 -0
- package/src/semconv/values.ts +2 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { ObservMeConfig } from "../config/schema.ts";
|
|
2
|
+
import { assertNoSensitiveQueryInput } from "../safety/sensitive-input.ts";
|
|
3
|
+
|
|
4
|
+
interface TraceTemplateReplacement {
|
|
5
|
+
readonly pattern: RegExp;
|
|
6
|
+
readonly key: TraceTemplateValueKey;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
type TraceTemplateValueKey = "traceId" | "tempoDatasourceUid";
|
|
10
|
+
type TraceTemplateValues = Record<TraceTemplateValueKey, string>;
|
|
11
|
+
|
|
12
|
+
interface GrafanaExploreDatasourceRef {
|
|
13
|
+
readonly type: "tempo";
|
|
14
|
+
readonly uid: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface GrafanaTraceQuery {
|
|
18
|
+
readonly refId: "A";
|
|
19
|
+
readonly datasource: GrafanaExploreDatasourceRef;
|
|
20
|
+
readonly queryType: "traceId";
|
|
21
|
+
readonly query: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface GrafanaExplorePane {
|
|
25
|
+
readonly datasource: string;
|
|
26
|
+
readonly queries: readonly GrafanaTraceQuery[];
|
|
27
|
+
readonly range: {
|
|
28
|
+
readonly from: "now-1h";
|
|
29
|
+
readonly to: "now";
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
type GrafanaExplorePanes = Record<string, GrafanaExplorePane>;
|
|
34
|
+
|
|
35
|
+
export const TRACE_LINK_CONFIGURATION_ERROR =
|
|
36
|
+
"Grafana trace link configuration is invalid: use an absolute HTTP(S) traceUrlTemplate with {traceId}, {{traceId}}, ${traceId}, or %TRACE_ID%, or use the ellipsis fallback with an absolute query.grafana.url.";
|
|
37
|
+
|
|
38
|
+
const maximumTraceLinkLength = 4096;
|
|
39
|
+
const traceIdPattern = /^[a-f0-9]{32}$/iu;
|
|
40
|
+
const zeroTraceIdPattern = /^0{32}$/u;
|
|
41
|
+
const traceIdTemplatePattern = /\{\{\s*traceId\s*\}\}|\{traceId\}|\$\{traceId\}|%TRACE_ID%/u;
|
|
42
|
+
const fallbackTraceTemplateMarkerPattern = /\.\.\./u;
|
|
43
|
+
const unresolvedTemplatePlaceholderPatterns: readonly RegExp[] = [
|
|
44
|
+
/\{\{\s*[A-Za-z]\w*\s*\}\}/u,
|
|
45
|
+
/\$\{[A-Za-z]\w*\}/u,
|
|
46
|
+
/\{[A-Za-z]\w*\}/u,
|
|
47
|
+
/%[A-Z][A-Z0-9_]*%/u,
|
|
48
|
+
/\$traceId\b/u,
|
|
49
|
+
/__TRACE_ID__/u,
|
|
50
|
+
];
|
|
51
|
+
const traceTemplateReplacements: readonly TraceTemplateReplacement[] = [
|
|
52
|
+
{ pattern: /\{\{\s*traceId\s*\}\}/gu, key: "traceId" },
|
|
53
|
+
{ pattern: /\$\{traceId\}/gu, key: "traceId" },
|
|
54
|
+
{ pattern: /\{traceId\}/gu, key: "traceId" },
|
|
55
|
+
{ pattern: /%TRACE_ID%/gu, key: "traceId" },
|
|
56
|
+
{ pattern: /\{\{\s*tempoDatasourceUid\s*\}\}/gu, key: "tempoDatasourceUid" },
|
|
57
|
+
{ pattern: /\$\{tempoDatasourceUid\}/gu, key: "tempoDatasourceUid" },
|
|
58
|
+
{ pattern: /\{tempoDatasourceUid\}/gu, key: "tempoDatasourceUid" },
|
|
59
|
+
{ pattern: /%TEMPO_DATASOURCE_UID%/gu, key: "tempoDatasourceUid" },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export function buildGrafanaTraceLink(config: ObservMeConfig, traceId: string): string {
|
|
63
|
+
const normalizedTraceId = normalizeTraceId(traceId);
|
|
64
|
+
const template = config.query.links.traceUrlTemplate.trim();
|
|
65
|
+
|
|
66
|
+
if (isFallbackTraceTemplate(template)) return buildDefaultGrafanaTraceLink(config, normalizedTraceId);
|
|
67
|
+
if (!hasTraceIdTemplatePlaceholder(template)) throw new Error(TRACE_LINK_CONFIGURATION_ERROR);
|
|
68
|
+
|
|
69
|
+
return renderTraceUrlTemplate(template, config, normalizedTraceId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildDefaultGrafanaTraceLink(config: ObservMeConfig, traceId: string): string {
|
|
73
|
+
const url = parseTraceLinkUrl(config.query.grafana.url.trim());
|
|
74
|
+
const basePath = removeTrailingSlashes(url.pathname);
|
|
75
|
+
url.pathname = `${basePath}/explore`;
|
|
76
|
+
url.search = "";
|
|
77
|
+
url.hash = "";
|
|
78
|
+
url.searchParams.set("schemaVersion", "1");
|
|
79
|
+
url.searchParams.set("panes", JSON.stringify(createDefaultExplorePanes(config, traceId)));
|
|
80
|
+
return formatTraceLinkUrl(url);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function removeTrailingSlashes(value: string): string {
|
|
84
|
+
let end = value.length;
|
|
85
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
86
|
+
return value.slice(0, end);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function createDefaultExplorePanes(config: ObservMeConfig, traceId: string): GrafanaExplorePanes {
|
|
90
|
+
const tempoUid = config.query.grafana.datasourceUids.tempo;
|
|
91
|
+
return {
|
|
92
|
+
observmeTrace: {
|
|
93
|
+
datasource: tempoUid,
|
|
94
|
+
queries: [
|
|
95
|
+
{
|
|
96
|
+
refId: "A",
|
|
97
|
+
datasource: { type: "tempo", uid: tempoUid },
|
|
98
|
+
queryType: "traceId",
|
|
99
|
+
query: traceId,
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
range: { from: "now-1h", to: "now" },
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function renderTraceUrlTemplate(template: string, config: ObservMeConfig, traceId: string): string {
|
|
108
|
+
const values = createTraceTemplateValues(config, traceId);
|
|
109
|
+
let rendered = template;
|
|
110
|
+
|
|
111
|
+
for (const replacement of traceTemplateReplacements) {
|
|
112
|
+
rendered = rendered.replace(replacement.pattern, values[replacement.key]);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (hasUnresolvedTemplatePlaceholder(rendered)) throw new Error(TRACE_LINK_CONFIGURATION_ERROR);
|
|
116
|
+
return formatTraceLinkUrl(parseTraceLinkUrl(rendered));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hasUnresolvedTemplatePlaceholder(value: string): boolean {
|
|
120
|
+
for (const pattern of unresolvedTemplatePlaceholderPatterns) {
|
|
121
|
+
if (pattern.test(value)) return true;
|
|
122
|
+
}
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function createTraceTemplateValues(config: ObservMeConfig, traceId: string): TraceTemplateValues {
|
|
127
|
+
return {
|
|
128
|
+
traceId: encodeURIComponent(traceId),
|
|
129
|
+
tempoDatasourceUid: encodeURIComponent(config.query.grafana.datasourceUids.tempo),
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeTraceId(traceId: string): string {
|
|
134
|
+
const trimmed = traceId.trim();
|
|
135
|
+
assertNoSensitiveQueryInput(trimmed, "Grafana traceId");
|
|
136
|
+
|
|
137
|
+
if (!traceIdPattern.test(trimmed) || zeroTraceIdPattern.test(trimmed)) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
"Unsafe Grafana traceId: expected a non-zero 32-character hexadecimal OpenTelemetry trace id; raw prompts, commands, paths, and environment values are not query inputs.",
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return trimmed.toLowerCase();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function hasTraceIdTemplatePlaceholder(template: string): boolean {
|
|
147
|
+
return traceIdTemplatePattern.test(template);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isFallbackTraceTemplate(template: string): boolean {
|
|
151
|
+
return template === "" || fallbackTraceTemplateMarkerPattern.test(template);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseTraceLinkUrl(value: string): URL {
|
|
155
|
+
if (!value || value.length > maximumTraceLinkLength || hasUnsafeUrlCharacters(value)) {
|
|
156
|
+
throw new Error(TRACE_LINK_CONFIGURATION_ERROR);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
try {
|
|
160
|
+
const url = new URL(value);
|
|
161
|
+
if (!isSupportedTraceLinkProtocol(url.protocol) || url.username || url.password) {
|
|
162
|
+
throw new Error(TRACE_LINK_CONFIGURATION_ERROR);
|
|
163
|
+
}
|
|
164
|
+
return url;
|
|
165
|
+
} catch {
|
|
166
|
+
throw new Error(TRACE_LINK_CONFIGURATION_ERROR);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function formatTraceLinkUrl(url: URL): string {
|
|
171
|
+
const link = url.toString();
|
|
172
|
+
if (link.length > maximumTraceLinkLength) throw new Error(TRACE_LINK_CONFIGURATION_ERROR);
|
|
173
|
+
return link;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function hasUnsafeUrlCharacters(value: string): boolean {
|
|
177
|
+
for (const character of value) {
|
|
178
|
+
const codePoint = character.codePointAt(0);
|
|
179
|
+
if (codePoint !== undefined && (codePoint <= 31 || codePoint === 127)) return true;
|
|
180
|
+
}
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function isSupportedTraceLinkProtocol(protocol: string): boolean {
|
|
185
|
+
return protocol === "http:" || protocol === "https:";
|
|
186
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const OBS_BACKEND_LABEL_MAX_CHARS = 96;
|
|
2
|
+
export const OBS_COMMAND_OUTPUT_MAX_CHARS = 8192;
|
|
3
|
+
export const OBS_COMMAND_OUTPUT_MAX_ROWS = 64;
|
|
4
|
+
export const OBS_COMMAND_RENDER_ROW_LIMIT = 20;
|
|
5
|
+
|
|
6
|
+
const truncationMarker = "…";
|
|
7
|
+
const outputTruncationSuffix = "\n… output truncated";
|
|
8
|
+
const unsafeDisplayControlPattern = /[\p{Cc}\p{Zl}\p{Zp}]+/gu;
|
|
9
|
+
const displayWhitespacePattern = /\s+/gu;
|
|
10
|
+
|
|
11
|
+
export interface BoundedObsCommandRows<T> {
|
|
12
|
+
readonly rows: readonly T[];
|
|
13
|
+
readonly omittedCount: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeObsBackendLabel(value: string | undefined): string | undefined {
|
|
17
|
+
const normalized = value?.replace(unsafeDisplayControlPattern, " ").replace(displayWhitespacePattern, " ").trim();
|
|
18
|
+
if (!normalized) return undefined;
|
|
19
|
+
return truncateUnicodeText(normalized, OBS_BACKEND_LABEL_MAX_CHARS, truncationMarker);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function normalizeObsBackendLabelRecord(labels: Readonly<Record<string, string>>): Record<string, string> {
|
|
23
|
+
const normalizedLabels: Record<string, string> = {};
|
|
24
|
+
|
|
25
|
+
for (const [key, value] of Object.entries(labels)) {
|
|
26
|
+
const normalizedKey = normalizeObsBackendLabel(key);
|
|
27
|
+
const normalizedValue = normalizeObsBackendLabel(value);
|
|
28
|
+
if (normalizedKey && normalizedValue !== undefined) normalizedLabels[normalizedKey] = normalizedValue;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return normalizedLabels;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function selectObsCommandRows<T>(rows: readonly T[]): BoundedObsCommandRows<T> {
|
|
35
|
+
if (rows.length <= OBS_COMMAND_RENDER_ROW_LIMIT) return { rows, omittedCount: 0 };
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
rows: rows.slice(0, OBS_COMMAND_RENDER_ROW_LIMIT),
|
|
39
|
+
omittedCount: rows.length - OBS_COMMAND_RENDER_ROW_LIMIT,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function boundObsCommandOutput(output: string): string {
|
|
44
|
+
const normalized = normalizeObsCommandOutput(output);
|
|
45
|
+
const rows = normalized.split("\n");
|
|
46
|
+
const rowsTruncated = rows.length > OBS_COMMAND_OUTPUT_MAX_ROWS;
|
|
47
|
+
const content = rowsTruncated ? rows.slice(0, OBS_COMMAND_OUTPUT_MAX_ROWS - 1).join("\n") : normalized;
|
|
48
|
+
|
|
49
|
+
if (!rowsTruncated && content.length <= OBS_COMMAND_OUTPUT_MAX_CHARS) return content;
|
|
50
|
+
|
|
51
|
+
const prefixLimit = OBS_COMMAND_OUTPUT_MAX_CHARS - outputTruncationSuffix.length;
|
|
52
|
+
const characterBounded = truncateUnicodeText(content, prefixLimit, "");
|
|
53
|
+
const rowBounded = selectOutputRows(characterBounded, OBS_COMMAND_OUTPUT_MAX_ROWS - 1);
|
|
54
|
+
return `${rowBounded}${outputTruncationSuffix}`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function normalizeObsCommandOutput(output: string): string {
|
|
58
|
+
return output.replace(unsafeDisplayControlPattern, normalizeObsCommandControlSequence);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function normalizeObsCommandControlSequence(sequence: string): string {
|
|
62
|
+
let normalized = "";
|
|
63
|
+
for (const character of sequence) normalized += character === "\n" ? "\n" : " ";
|
|
64
|
+
return normalized;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function selectOutputRows(output: string, maximumRows: number): string {
|
|
68
|
+
const rows = output.split("\n");
|
|
69
|
+
if (rows.length <= maximumRows) return output;
|
|
70
|
+
return rows.slice(0, maximumRows).join("\n");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function truncateUnicodeText(value: string, maximumChars: number, suffix: string): string {
|
|
74
|
+
if (value.length <= maximumChars) return value;
|
|
75
|
+
|
|
76
|
+
const prefixLimit = Math.max(0, maximumChars - suffix.length);
|
|
77
|
+
let prefix = "";
|
|
78
|
+
for (const codePoint of value) {
|
|
79
|
+
if (prefix.length + codePoint.length > prefixLimit) break;
|
|
80
|
+
prefix += codePoint;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return `${prefix}${suffix}`;
|
|
84
|
+
}
|
package/src/semconv/metrics.ts
CHANGED
|
@@ -95,6 +95,7 @@ export const LOG_EVENT_NAMES = {
|
|
|
95
95
|
AGENT_SPAWN_STARTED: "agent.spawn.started",
|
|
96
96
|
AGENT_SPAWN_COMPLETED: "agent.spawn.completed",
|
|
97
97
|
AGENT_SPAWN_FAILED: "agent.spawn.failed",
|
|
98
|
+
AGENT_SPAWN_CANCELLED: "agent.spawn.cancelled",
|
|
98
99
|
AGENT_WAIT_STARTED: "agent.wait.started",
|
|
99
100
|
AGENT_WAIT_COMPLETED: "agent.wait.completed",
|
|
100
101
|
AGENT_JOIN_STARTED: "agent.join.started",
|