@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.
- package/.env.example +57 -0
- package/CHANGELOG.md +52 -0
- package/LICENSE +21 -0
- package/ObservMe-Production-Docs/00-README.md +79 -0
- package/ObservMe-Production-Docs/01-requirements-and-scope.md +207 -0
- package/ObservMe-Production-Docs/02-reference-architecture.md +306 -0
- package/ObservMe-Production-Docs/03-pi-event-and-session-model.md +266 -0
- package/ObservMe-Production-Docs/04-telemetry-semantic-conventions.md +722 -0
- package/ObservMe-Production-Docs/05-otel-pipeline-and-collector.md +355 -0
- package/ObservMe-Production-Docs/06-security-privacy-redaction.md +294 -0
- package/ObservMe-Production-Docs/07-extension-implementation-blueprint.md +447 -0
- package/ObservMe-Production-Docs/08-query-grafana-integration.md +276 -0
- package/ObservMe-Production-Docs/09-dashboards-alerts-slos.md +640 -0
- package/ObservMe-Production-Docs/10-testing-release-operations.md +319 -0
- package/ObservMe-Production-Docs/11-deployment-runbooks.md +203 -0
- package/ObservMe-Production-Docs/12-configuration-reference.md +337 -0
- package/ObservMe-Production-Docs/13-source-notes.md +33 -0
- package/ObservMe-Production-Docs/pi-session-format.md +427 -0
- package/README.md +356 -0
- package/SECURITY.md +45 -0
- package/dashboards/observme-agent-node-graphs.json +250 -0
- package/dashboards/observme-agents.json +1880 -0
- package/dashboards/observme-alerts.yaml +113 -0
- package/dashboards/observme-branches-compactions.json +1042 -0
- package/dashboards/observme-cost.json +1254 -0
- package/dashboards/observme-errors.json +1659 -0
- package/dashboards/observme-export-health.json +1802 -0
- package/dashboards/observme-latency.json +1494 -0
- package/dashboards/observme-llm-conversations.json +730 -0
- package/dashboards/observme-logs-llm.json +644 -0
- package/dashboards/observme-models.json +933 -0
- package/dashboards/observme-overview.json +2129 -0
- package/dashboards/observme-slo-health.json +737 -0
- package/dashboards/observme-slos.yaml +56 -0
- package/dashboards/observme-tools.json +902 -0
- package/dashboards/observme-trace-journey.json +1793 -0
- package/docs/STRUCTURE.md +49 -0
- package/docs/agent-subagent-observability-requirements.md +997 -0
- package/docs/compatibility-matrix.md +39 -0
- package/docs/configuration-tui-design-standard.md +767 -0
- package/docs/configuration.md +38 -0
- package/docs/review-validation.md +115 -0
- package/docs/validation-flow.md +117 -0
- package/examples/collector.yaml +123 -0
- package/examples/observme.yaml +131 -0
- package/img/demo.gif +0 -0
- package/img/icon.svg +47 -0
- package/package.json +103 -0
- package/src/commands/obs-agents-runtime.ts +150 -0
- package/src/commands/obs-agents.ts +491 -0
- package/src/commands/obs-args.ts +63 -0
- package/src/commands/obs-backfill.ts +1334 -0
- package/src/commands/obs-command-support.ts +43 -0
- package/src/commands/obs-cost.ts +228 -0
- package/src/commands/obs-diagnostics.ts +22 -0
- package/src/commands/obs-errors.ts +156 -0
- package/src/commands/obs-health.ts +301 -0
- package/src/commands/obs-link.ts +90 -0
- package/src/commands/obs-logs.ts +194 -0
- package/src/commands/obs-loki-summary.ts +184 -0
- package/src/commands/obs-session.ts +259 -0
- package/src/commands/obs-status.ts +359 -0
- package/src/commands/obs-tools.ts +274 -0
- package/src/commands/obs-trace.ts +411 -0
- package/src/commands/obs.ts +211 -0
- package/src/config/bootstrap-project-config.ts +300 -0
- package/src/config/defaults.ts +143 -0
- package/src/config/load-config.ts +631 -0
- package/src/config/project-paths.ts +61 -0
- package/src/config/schema.ts +405 -0
- package/src/config/validate.ts +456 -0
- package/src/constants.ts +4 -0
- package/src/diagnostics/sanitize.ts +6 -0
- package/src/extension.ts +38 -0
- package/src/otel/logs.ts +160 -0
- package/src/otel/metrics.ts +165 -0
- package/src/otel/otlp-endpoint.ts +10 -0
- package/src/otel/sdk.ts +114 -0
- package/src/otel/shutdown.ts +102 -0
- package/src/otel/traces.ts +166 -0
- package/src/pi/agent-lineage.ts +378 -0
- package/src/pi/agent-tree-tracker.ts +258 -0
- package/src/pi/event-handlers/agent-turn.ts +155 -0
- package/src/pi/event-handlers/lifecycle.ts +642 -0
- package/src/pi/event-handlers/llm.ts +115 -0
- package/src/pi/event-handlers/session-events.ts +159 -0
- package/src/pi/event-handlers/tool-bash.ts +275 -0
- package/src/pi/handler-internals.ts +2154 -0
- package/src/pi/handler-runtime.ts +633 -0
- package/src/pi/handler-types.ts +261 -0
- package/src/pi/handlers.ts +75 -0
- package/src/pi/subagent-spawn.ts +975 -0
- package/src/pi/subagent-types.ts +29 -0
- package/src/privacy/content-capture.ts +104 -0
- package/src/privacy/hash.ts +93 -0
- package/src/privacy/redact.ts +619 -0
- package/src/privacy/secret-patterns.ts +185 -0
- package/src/privacy/truncate.ts +69 -0
- package/src/query/grafana-readiness.ts +164 -0
- package/src/query/grafana-transport.ts +481 -0
- package/src/query/grafana.ts +371 -0
- package/src/query/loki.ts +332 -0
- package/src/query/prometheus.ts +388 -0
- package/src/query/tempo.ts +332 -0
- package/src/safety/sensitive-input.ts +208 -0
- package/src/semconv/attributes.ts +279 -0
- package/src/semconv/metrics.ts +146 -0
- package/src/semconv/spans.ts +19 -0
- package/src/semconv/values.ts +13 -0
- package/src/util/bounded-map.ts +97 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { LoadSessionConfigOptions, SessionConfigDiagnostics, SessionConfigEffectiveSource } from "../config/load-config.ts";
|
|
3
|
+
import { loadSessionConfigWithDiagnostics } from "../config/load-config.ts";
|
|
4
|
+
import type { CaptureConfig, ObservMeConfig } from "../config/schema.ts";
|
|
5
|
+
import { getGrafanaQueryReadiness } from "../query/grafana-readiness.ts";
|
|
6
|
+
import { completeObsSubcommand, isExactObsSubcommandRequest } from "./obs-args.ts";
|
|
7
|
+
import { sanitizeObsDiagnosticText } from "./obs-diagnostics.ts";
|
|
8
|
+
|
|
9
|
+
export interface ObsStatusCommandContext {
|
|
10
|
+
readonly cwd?: string;
|
|
11
|
+
readonly ui: {
|
|
12
|
+
notify: (message: string, type?: "info" | "warning" | "error") => Promise<void> | void;
|
|
13
|
+
};
|
|
14
|
+
readonly isProjectTrusted?: () => boolean | Promise<boolean>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ObsStatusSnapshot {
|
|
18
|
+
readonly config: ObservMeConfig;
|
|
19
|
+
readonly queueDrops: number;
|
|
20
|
+
readonly lastExportError?: string;
|
|
21
|
+
readonly configDiagnostics?: SessionConfigDiagnostics;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ObsStatusRuntimeStatePatch {
|
|
25
|
+
readonly config?: ObservMeConfig;
|
|
26
|
+
readonly queueDrops?: number;
|
|
27
|
+
readonly lastExportError?: string;
|
|
28
|
+
readonly configDiagnostics?: SessionConfigDiagnostics;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ObsStatusRuntimeState {
|
|
32
|
+
readonly config?: ObservMeConfig;
|
|
33
|
+
readonly queueDrops: number;
|
|
34
|
+
readonly lastExportError?: string;
|
|
35
|
+
readonly configDiagnostics?: SessionConfigDiagnostics;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ObsStatusOperationResult {
|
|
39
|
+
readonly operation: string;
|
|
40
|
+
readonly completed?: boolean;
|
|
41
|
+
readonly timedOut?: boolean;
|
|
42
|
+
readonly error?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type ObsStatusConfigLoader = (options: LoadSessionConfigOptions) => Promise<ObservMeConfig>;
|
|
46
|
+
export type ObsStatusProvider = (ctx: ObsStatusCommandContext) => Promise<ObsStatusSnapshot> | ObsStatusSnapshot;
|
|
47
|
+
type ObsStatusReadText = NonNullable<LoadSessionConfigOptions["readText"]>;
|
|
48
|
+
type ObsStatusRuntimeOptions = NonNullable<LoadSessionConfigOptions["runtimeOptions"]>;
|
|
49
|
+
type ObsStatusLogger = NonNullable<LoadSessionConfigOptions["logger"]>;
|
|
50
|
+
|
|
51
|
+
export interface ObsStatusSnapshotOptions {
|
|
52
|
+
readonly loadConfig?: ObsStatusConfigLoader;
|
|
53
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
54
|
+
readonly configDirName?: string;
|
|
55
|
+
readonly globalConfigPath?: string;
|
|
56
|
+
readonly projectConfigPath?: string;
|
|
57
|
+
readonly readText?: ObsStatusReadText;
|
|
58
|
+
readonly runtimeOptions?: ObsStatusRuntimeOptions;
|
|
59
|
+
readonly logger?: ObsStatusLogger;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RegisterObsStatusCommandOptions extends ObsStatusSnapshotOptions {
|
|
63
|
+
readonly getStatus?: ObsStatusProvider;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const OBS_COMMAND_NAME = "obs";
|
|
67
|
+
const OBS_STATUS_SUBCOMMAND = "status";
|
|
68
|
+
|
|
69
|
+
interface MutableObsStatusRuntimeState {
|
|
70
|
+
config?: ObservMeConfig;
|
|
71
|
+
queueDrops: number;
|
|
72
|
+
lastExportError?: string;
|
|
73
|
+
configDiagnostics?: SessionConfigDiagnostics;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const runtimeStatusState: MutableObsStatusRuntimeState = {
|
|
77
|
+
queueDrops: 0,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export function registerObsStatusCommand(pi: ExtensionAPI, options: RegisterObsStatusCommandOptions = {}): void {
|
|
81
|
+
const command = new ObsStatusCommand(options);
|
|
82
|
+
|
|
83
|
+
pi.registerCommand(OBS_COMMAND_NAME, {
|
|
84
|
+
description: "Show local ObservMe status. Usage: /obs status",
|
|
85
|
+
getArgumentCompletions: getObsCommandArgumentCompletions,
|
|
86
|
+
handler: command.handle.bind(command),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function handleObsStatusCommand(
|
|
91
|
+
args: string,
|
|
92
|
+
ctx: ObsStatusCommandContext,
|
|
93
|
+
options: RegisterObsStatusCommandOptions = {},
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
if (!isObsStatusRequest(args)) {
|
|
96
|
+
await notifyStatus(ctx, "Usage: /obs status", "warning");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
const snapshot = await resolveObsStatusSnapshot(ctx, options);
|
|
102
|
+
await notifyStatus(ctx, renderObsStatus(snapshot), "info");
|
|
103
|
+
} catch (error) {
|
|
104
|
+
await notifyStatus(ctx, `ObservMe status unavailable: ${sanitizeObsDiagnosticText(formatError(error))}`, "error");
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function getObsCommandArgumentCompletions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
109
|
+
return completeObsSubcommand(prefix, OBS_STATUS_SUBCOMMAND);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function getLocalObsStatusSnapshot(
|
|
113
|
+
ctx: ObsStatusCommandContext,
|
|
114
|
+
options: ObsStatusSnapshotOptions = {},
|
|
115
|
+
): Promise<ObsStatusSnapshot> {
|
|
116
|
+
const state = getObsStatusRuntimeState();
|
|
117
|
+
const loaded = state.config ? { config: state.config, diagnostics: state.configDiagnostics } : await loadObsStatusConfig(ctx, options);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
config: loaded.config,
|
|
121
|
+
queueDrops: normalizeQueueDrops(state.queueDrops),
|
|
122
|
+
lastExportError: normalizeLastExportError(state.lastExportError),
|
|
123
|
+
configDiagnostics: loaded.diagnostics,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function renderObsStatus(snapshot: ObsStatusSnapshot): string {
|
|
128
|
+
const config = snapshot.config;
|
|
129
|
+
const lines = [
|
|
130
|
+
`ObservMe: ${formatEnabled(config.enabled)}`,
|
|
131
|
+
`OTLP endpoint: ${formatSafeConfiguredEndpoint(config.otlp.endpoint)}`,
|
|
132
|
+
...formatConfigDiagnosticsLines(snapshot.configDiagnostics),
|
|
133
|
+
`Grafana URL: ${formatSafeConfiguredUrl(config.query.grafana.url)}`,
|
|
134
|
+
`Grafana query readiness: ${formatGrafanaQueryReadiness(config)}`,
|
|
135
|
+
`Traces: ${formatEnabled(signalEnabled(config, config.traces.enabled))}`,
|
|
136
|
+
`Metrics: ${formatEnabled(signalEnabled(config, config.metrics.enabled))}`,
|
|
137
|
+
`Logs: ${formatEnabled(signalEnabled(config, config.logs.enabled))}`,
|
|
138
|
+
...formatCaptureLines(config.capture),
|
|
139
|
+
`Queue drops: ${normalizeQueueDrops(snapshot.queueDrops)}`,
|
|
140
|
+
`Last export error: ${normalizeLastExportError(snapshot.lastExportError) ?? "none"}`,
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
return lines.join("\n");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function updateObsStatusRuntimeState(patch: ObsStatusRuntimeStatePatch): void {
|
|
147
|
+
if (patch.config) {
|
|
148
|
+
runtimeStatusState.config = structuredClone(patch.config);
|
|
149
|
+
delete runtimeStatusState.configDiagnostics;
|
|
150
|
+
}
|
|
151
|
+
if (patch.configDiagnostics) runtimeStatusState.configDiagnostics = structuredClone(patch.configDiagnostics);
|
|
152
|
+
if (patch.queueDrops !== undefined) runtimeStatusState.queueDrops = normalizeQueueDrops(patch.queueDrops);
|
|
153
|
+
if (patch.lastExportError !== undefined) runtimeStatusState.lastExportError = normalizeLastExportError(patch.lastExportError);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function resetObsStatusRuntimeState(): void {
|
|
157
|
+
delete runtimeStatusState.config;
|
|
158
|
+
delete runtimeStatusState.lastExportError;
|
|
159
|
+
delete runtimeStatusState.configDiagnostics;
|
|
160
|
+
runtimeStatusState.queueDrops = 0;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function getObsStatusRuntimeState(): ObsStatusRuntimeState {
|
|
164
|
+
return {
|
|
165
|
+
config: runtimeStatusState.config ? structuredClone(runtimeStatusState.config) : undefined,
|
|
166
|
+
queueDrops: normalizeQueueDrops(runtimeStatusState.queueDrops),
|
|
167
|
+
lastExportError: normalizeLastExportError(runtimeStatusState.lastExportError),
|
|
168
|
+
configDiagnostics: runtimeStatusState.configDiagnostics ? structuredClone(runtimeStatusState.configDiagnostics) : undefined,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function recordObsStatusQueueDrop(count = 1): void {
|
|
173
|
+
runtimeStatusState.queueDrops = normalizeQueueDrops(runtimeStatusState.queueDrops) + normalizeQueueDrops(count);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function recordObsStatusExportResult(result: ObsStatusOperationResult): void {
|
|
177
|
+
if (result.timedOut) {
|
|
178
|
+
runtimeStatusState.lastExportError = `${result.operation} timed out`;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (result.error) runtimeStatusState.lastExportError = sanitizeObsDiagnosticText(`${result.operation} failed: ${formatError(result.error)}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function clearObsStatusExportError(): void {
|
|
186
|
+
delete runtimeStatusState.lastExportError;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
class ObsStatusCommand {
|
|
190
|
+
readonly #options: RegisterObsStatusCommandOptions;
|
|
191
|
+
|
|
192
|
+
constructor(options: RegisterObsStatusCommandOptions) {
|
|
193
|
+
this.#options = options;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async handle(args: string, ctx: ObsStatusCommandContext): Promise<void> {
|
|
197
|
+
await handleObsStatusCommand(args, ctx, this.#options);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
async function resolveObsStatusSnapshot(
|
|
202
|
+
ctx: ObsStatusCommandContext,
|
|
203
|
+
options: RegisterObsStatusCommandOptions,
|
|
204
|
+
): Promise<ObsStatusSnapshot> {
|
|
205
|
+
if (options.getStatus) return options.getStatus(ctx);
|
|
206
|
+
return getLocalObsStatusSnapshot(ctx, options);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function loadObsStatusConfig(
|
|
210
|
+
ctx: ObsStatusCommandContext,
|
|
211
|
+
options: ObsStatusSnapshotOptions,
|
|
212
|
+
): Promise<{ config: ObservMeConfig; diagnostics?: SessionConfigDiagnostics }> {
|
|
213
|
+
const loadOptions = createObsStatusLoadOptions(ctx, options);
|
|
214
|
+
|
|
215
|
+
if (options.loadConfig) return { config: await options.loadConfig(loadOptions) };
|
|
216
|
+
return loadSessionConfigWithDiagnostics(loadOptions);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function createObsStatusLoadOptions(
|
|
220
|
+
ctx: ObsStatusCommandContext,
|
|
221
|
+
options: ObsStatusSnapshotOptions,
|
|
222
|
+
): LoadSessionConfigOptions {
|
|
223
|
+
return {
|
|
224
|
+
ctx,
|
|
225
|
+
cwd: ctx.cwd,
|
|
226
|
+
configDirName: options.configDirName,
|
|
227
|
+
env: options.env,
|
|
228
|
+
globalConfigPath: options.globalConfigPath,
|
|
229
|
+
projectConfigPath: options.projectConfigPath,
|
|
230
|
+
readText: options.readText,
|
|
231
|
+
runtimeOptions: options.runtimeOptions,
|
|
232
|
+
logger: options.logger,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isObsStatusRequest(args: string): boolean {
|
|
237
|
+
return isExactObsSubcommandRequest(args, OBS_STATUS_SUBCOMMAND, { allowEmpty: true });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function notifyStatus(ctx: ObsStatusCommandContext, message: string, type: "info" | "warning" | "error"): Promise<void> {
|
|
241
|
+
await ctx.ui?.notify?.(message, type);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function formatCaptureLines(capture: CaptureConfig): string[] {
|
|
245
|
+
return [
|
|
246
|
+
`Prompt capture: ${formatEnabled(capture.prompts)}`,
|
|
247
|
+
`Response capture: ${formatEnabled(capture.responses)}`,
|
|
248
|
+
`Thinking capture: ${formatEnabled(capture.thinking)}`,
|
|
249
|
+
`Tool argument capture: ${formatEnabled(capture.toolArguments)}`,
|
|
250
|
+
`Tool result capture: ${formatEnabled(capture.toolResults)}`,
|
|
251
|
+
`Bash command capture: ${formatEnabled(capture.bashCommands)}`,
|
|
252
|
+
`Bash output capture: ${formatEnabled(capture.bashOutput)}`,
|
|
253
|
+
`File path capture: ${formatEnabled(capture.filePaths)}`,
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function formatConfigDiagnosticsLines(diagnostics: SessionConfigDiagnostics | undefined): string[] {
|
|
258
|
+
if (!diagnostics) return [];
|
|
259
|
+
|
|
260
|
+
return [
|
|
261
|
+
`Config source: ${formatConfigEffectiveSource(diagnostics.effectiveSource)}`,
|
|
262
|
+
`Project config: ${formatProjectConfigStatus(diagnostics)}`,
|
|
263
|
+
...formatConfigRejectionLines(diagnostics),
|
|
264
|
+
];
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function formatConfigRejectionLines(diagnostics: SessionConfigDiagnostics): string[] {
|
|
268
|
+
const rejection = diagnostics.rejection;
|
|
269
|
+
if (!rejection) return [];
|
|
270
|
+
|
|
271
|
+
return [
|
|
272
|
+
`Config rejection: safe defaults applied (${rejection.issueCount} issue(s): ${rejection.issueCodes.join(", ")})`,
|
|
273
|
+
];
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function formatConfigEffectiveSource(source: SessionConfigEffectiveSource): string {
|
|
277
|
+
if (source === "runtime_options") return "runtime options";
|
|
278
|
+
if (source === "environment") return "environment overrides";
|
|
279
|
+
if (source === "trusted_project") return "trusted project config (.pi/observme.yaml)";
|
|
280
|
+
if (source === "global") return "global config";
|
|
281
|
+
return "defaults";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function formatProjectConfigStatus(diagnostics: SessionConfigDiagnostics): string {
|
|
285
|
+
if (diagnostics.projectConfigStatus === "loaded") return "loaded (trusted .pi/observme.yaml)";
|
|
286
|
+
if (diagnostics.projectConfigStatus === "skipped_untrusted") {
|
|
287
|
+
return "skipped (project is untrusted; safe defaults/global/env only)";
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return "missing (trusted project has no .pi/observme.yaml)";
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function formatGrafanaQueryReadiness(config: ObservMeConfig): string {
|
|
294
|
+
const readiness = getGrafanaQueryReadiness(config);
|
|
295
|
+
const issueCodes = readiness.issues.map(issue => issue.code).join(", ");
|
|
296
|
+
return issueCodes ? `${readiness.status} (${issueCodes})` : readiness.status;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatSafeConfiguredUrl(value: string): string {
|
|
300
|
+
const trimmed = value.trim();
|
|
301
|
+
if (!trimmed) return "not configured";
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
const parsed = new URL(trimmed);
|
|
305
|
+
parsed.username = "";
|
|
306
|
+
parsed.password = "";
|
|
307
|
+
parsed.search = "";
|
|
308
|
+
parsed.hash = "";
|
|
309
|
+
return parsed.toString();
|
|
310
|
+
} catch (error) {
|
|
311
|
+
return formatInvalidConfiguredUrl(error);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function signalEnabled(config: ObservMeConfig, enabled: boolean): boolean {
|
|
316
|
+
return config.enabled && enabled;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function formatEnabled(enabled: boolean): "enabled" | "disabled" {
|
|
320
|
+
return enabled ? "enabled" : "disabled";
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function normalizeQueueDrops(value: number | undefined): number {
|
|
324
|
+
if (value === undefined || !Number.isFinite(value) || value < 0) return 0;
|
|
325
|
+
return Math.trunc(value);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function normalizeLastExportError(value: string | undefined): string | undefined {
|
|
329
|
+
const trimmed = value?.trim();
|
|
330
|
+
return trimmed ? sanitizeObsDiagnosticText(trimmed) : undefined;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function formatSafeConfiguredEndpoint(value: string): string {
|
|
334
|
+
const trimmed = value.trim();
|
|
335
|
+
if (!trimmed) return "not configured";
|
|
336
|
+
|
|
337
|
+
try {
|
|
338
|
+
const parsed = new URL(trimmed);
|
|
339
|
+
const hasExplicitRootPath = trimmed.endsWith("/");
|
|
340
|
+
parsed.username = "";
|
|
341
|
+
parsed.password = "";
|
|
342
|
+
parsed.search = "";
|
|
343
|
+
parsed.hash = "";
|
|
344
|
+
const formatted = parsed.toString();
|
|
345
|
+
if (parsed.pathname === "/" && !hasExplicitRootPath) return formatted.replace(/\/$/u, "");
|
|
346
|
+
return formatted;
|
|
347
|
+
} catch (error) {
|
|
348
|
+
return formatInvalidConfiguredUrl(error);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function formatInvalidConfiguredUrl(error: unknown): string {
|
|
353
|
+
const failureKind = error instanceof Error ? error.name : "unknown parser failure";
|
|
354
|
+
return `invalid configured URL (${failureKind})`;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function formatError(error: unknown): string {
|
|
358
|
+
return error instanceof Error ? error.message : String(error);
|
|
359
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { LoadSessionConfigOptions } from "../config/load-config.ts";
|
|
3
|
+
import type { ObservMeConfig } from "../config/schema.ts";
|
|
4
|
+
import type { PrometheusFetch, PrometheusMetricSeries, QueryResult } from "../query/prometheus.ts";
|
|
5
|
+
import { createPrometheusQueryClient } from "../query/prometheus.ts";
|
|
6
|
+
import { completeObsSubcommand, isExactObsSubcommandRequest } from "./obs-args.ts";
|
|
7
|
+
import { loadObsCommandConfig, notifyObsCommand } from "./obs-command-support.ts";
|
|
8
|
+
import { appendObsRecoveryHint, formatObsCommandFailure } from "./obs-diagnostics.ts";
|
|
9
|
+
|
|
10
|
+
export interface ObsToolsCommandContext {
|
|
11
|
+
readonly cwd?: string;
|
|
12
|
+
readonly ui: {
|
|
13
|
+
notify: (message: string, type?: "info" | "warning" | "error") => Promise<void> | void;
|
|
14
|
+
};
|
|
15
|
+
readonly isProjectTrusted?: () => boolean | Promise<boolean>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface ObsToolCallRow {
|
|
19
|
+
readonly toolName: string;
|
|
20
|
+
readonly ratePerSecond: number;
|
|
21
|
+
readonly timestampUnixSeconds?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ObsToolFailureRow {
|
|
25
|
+
readonly toolName: string;
|
|
26
|
+
readonly errorClass: string;
|
|
27
|
+
readonly ratePerSecond: number;
|
|
28
|
+
readonly timestampUnixSeconds?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ObsToolsSnapshot {
|
|
32
|
+
readonly window: "1h";
|
|
33
|
+
readonly callQuery: string;
|
|
34
|
+
readonly failureQuery: string;
|
|
35
|
+
readonly calls: readonly ObsToolCallRow[];
|
|
36
|
+
readonly failures: readonly ObsToolFailureRow[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type ObsToolsConfigLoader = (options: LoadSessionConfigOptions) => Promise<ObservMeConfig>;
|
|
40
|
+
export type ObsToolsProvider = (ctx: ObsToolsCommandContext) => Promise<ObsToolsSnapshot> | ObsToolsSnapshot;
|
|
41
|
+
|
|
42
|
+
export interface ObsToolsSnapshotOptions {
|
|
43
|
+
readonly loadConfig?: ObsToolsConfigLoader;
|
|
44
|
+
readonly fetch?: PrometheusFetch;
|
|
45
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
46
|
+
readonly configDirName?: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RegisterObsToolsCommandOptions extends ObsToolsSnapshotOptions {
|
|
50
|
+
readonly getTools?: ObsToolsProvider;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const OBS_TOOLS_CALLS_PROMQL = "topk(10, sum(rate(observme_tool_calls_total[1h])) by (tool_name))";
|
|
54
|
+
export const OBS_TOOLS_FAILURES_PROMQL = "sum(rate(observme_tool_failures_total[1h])) by (tool_name, error_class)";
|
|
55
|
+
|
|
56
|
+
const OBS_COMMAND_NAME = "obs";
|
|
57
|
+
const OBS_TOOLS_SUBCOMMAND = "tools";
|
|
58
|
+
const OBS_TOOLS_WINDOW = "1h";
|
|
59
|
+
const OBS_TOOLS_USAGE = "Usage: /obs tools";
|
|
60
|
+
const OBS_TOOLS_ERROR_NEXT_ACTION = "run /obs health and verify query.grafana.url, Grafana credentials, and the Metrics datasource UID.";
|
|
61
|
+
const OBS_TOOLS_NO_CALLS_NEXT_ACTION = "run tool activity, then verify the Metrics datasource with /obs health.";
|
|
62
|
+
const OBS_TOOLS_NO_FAILURES_NEXT_ACTION = "check after a failing tool call, then verify Metrics labels with /obs health.";
|
|
63
|
+
|
|
64
|
+
type ObsToolsRequestStatus = "tools" | "usage";
|
|
65
|
+
|
|
66
|
+
interface ObsToolsQueryResults {
|
|
67
|
+
readonly calls: QueryResult;
|
|
68
|
+
readonly failures: QueryResult;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function registerObsToolsCommand(pi: ExtensionAPI, options: RegisterObsToolsCommandOptions = {}): void {
|
|
72
|
+
const command = new ObsToolsCommand(options);
|
|
73
|
+
|
|
74
|
+
pi.registerCommand(OBS_COMMAND_NAME, {
|
|
75
|
+
description: "Show aggregate ObservMe tool call and failure rates. Usage: /obs tools",
|
|
76
|
+
getArgumentCompletions: getObsToolsCommandArgumentCompletions,
|
|
77
|
+
handler: command.handle.bind(command),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function handleObsToolsCommand(
|
|
82
|
+
args: string,
|
|
83
|
+
ctx: ObsToolsCommandContext,
|
|
84
|
+
options: RegisterObsToolsCommandOptions = {},
|
|
85
|
+
): Promise<void> {
|
|
86
|
+
if (parseObsToolsRequest(args) === "usage") {
|
|
87
|
+
await notifyObsCommand(ctx, OBS_TOOLS_USAGE, "warning");
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const snapshot = await resolveObsToolsSnapshot(ctx, options);
|
|
93
|
+
await notifyObsCommand(ctx, renderObsTools(snapshot), "info");
|
|
94
|
+
} catch (error) {
|
|
95
|
+
await notifyObsCommand(
|
|
96
|
+
ctx,
|
|
97
|
+
formatObsCommandFailure("ObservMe tools unavailable", error, {
|
|
98
|
+
subsystem: "Prometheus",
|
|
99
|
+
nextAction: OBS_TOOLS_ERROR_NEXT_ACTION,
|
|
100
|
+
}),
|
|
101
|
+
"error",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function getObsToolsCommandArgumentCompletions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
107
|
+
return completeObsSubcommand(prefix, OBS_TOOLS_SUBCOMMAND);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export async function getObsToolsSnapshot(
|
|
111
|
+
ctx: ObsToolsCommandContext,
|
|
112
|
+
options: ObsToolsSnapshotOptions = {},
|
|
113
|
+
): Promise<ObsToolsSnapshot> {
|
|
114
|
+
const config = await loadObsToolsConfig(ctx, options);
|
|
115
|
+
const result = await queryObsTools(config, options);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
window: OBS_TOOLS_WINDOW,
|
|
119
|
+
callQuery: OBS_TOOLS_CALLS_PROMQL,
|
|
120
|
+
failureQuery: OBS_TOOLS_FAILURES_PROMQL,
|
|
121
|
+
calls: result.calls.series.map(toObsToolCallRow).filter(isObsToolCallRow),
|
|
122
|
+
failures: result.failures.series.map(toObsToolFailureRow).filter(isObsToolFailureRow),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function renderObsTools(snapshot: ObsToolsSnapshot): string {
|
|
127
|
+
const calls = snapshot.calls.map(normalizeObsToolCallRow).filter(isObsToolCallRow);
|
|
128
|
+
const failures = snapshot.failures.map(normalizeObsToolFailureRow).filter(isObsToolFailureRow);
|
|
129
|
+
const lines = [`Tool calls by tool (last ${snapshot.window})`];
|
|
130
|
+
|
|
131
|
+
if (calls.length === 0) {
|
|
132
|
+
lines.push(appendObsRecoveryHint("No tool call metrics found.", OBS_TOOLS_NO_CALLS_NEXT_ACTION));
|
|
133
|
+
} else {
|
|
134
|
+
lines.push(...calls.map(renderObsToolCallRow));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
lines.push(`Tool failures by tool/error (last ${snapshot.window})`);
|
|
138
|
+
if (failures.length === 0) {
|
|
139
|
+
lines.push(appendObsRecoveryHint("No tool failure metrics found.", OBS_TOOLS_NO_FAILURES_NEXT_ACTION));
|
|
140
|
+
} else {
|
|
141
|
+
lines.push(...failures.map(renderObsToolFailureRow));
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return lines.join("\n");
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
class ObsToolsCommand {
|
|
148
|
+
readonly #options: RegisterObsToolsCommandOptions;
|
|
149
|
+
|
|
150
|
+
constructor(options: RegisterObsToolsCommandOptions) {
|
|
151
|
+
this.#options = options;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async handle(args: string, ctx: ObsToolsCommandContext): Promise<void> {
|
|
155
|
+
await handleObsToolsCommand(args, ctx, this.#options);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function resolveObsToolsSnapshot(
|
|
160
|
+
ctx: ObsToolsCommandContext,
|
|
161
|
+
options: RegisterObsToolsCommandOptions,
|
|
162
|
+
): Promise<ObsToolsSnapshot> {
|
|
163
|
+
if (options.getTools) return options.getTools(ctx);
|
|
164
|
+
return getObsToolsSnapshot(ctx, options);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function loadObsToolsConfig(ctx: ObsToolsCommandContext, options: ObsToolsSnapshotOptions): Promise<ObservMeConfig> {
|
|
168
|
+
return loadObsCommandConfig(ctx, options);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function queryObsTools(config: ObservMeConfig, options: ObsToolsSnapshotOptions): Promise<ObsToolsQueryResults> {
|
|
172
|
+
const client = createPrometheusQueryClient(config, { fetch: options.fetch });
|
|
173
|
+
const calls = await client.queryPrometheus(OBS_TOOLS_CALLS_PROMQL, undefined, { resultLimit: "metricSeries" });
|
|
174
|
+
const failures = await client.queryPrometheus(OBS_TOOLS_FAILURES_PROMQL, undefined, { resultLimit: "metricSeries" });
|
|
175
|
+
|
|
176
|
+
return { calls, failures };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function toObsToolCallRow(series: PrometheusMetricSeries): ObsToolCallRow | undefined {
|
|
180
|
+
const ratePerSecond = parseRatePerSecond(series.value?.value);
|
|
181
|
+
if (ratePerSecond === undefined) return undefined;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
toolName: normalizeMetricLabel(series.metric.tool_name),
|
|
185
|
+
ratePerSecond,
|
|
186
|
+
timestampUnixSeconds: series.value?.timestampUnixSeconds,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function toObsToolFailureRow(series: PrometheusMetricSeries): ObsToolFailureRow | undefined {
|
|
191
|
+
const ratePerSecond = parseRatePerSecond(series.value?.value);
|
|
192
|
+
if (ratePerSecond === undefined) return undefined;
|
|
193
|
+
|
|
194
|
+
return {
|
|
195
|
+
toolName: normalizeMetricLabel(series.metric.tool_name),
|
|
196
|
+
errorClass: normalizeMetricLabel(series.metric.error_class),
|
|
197
|
+
ratePerSecond,
|
|
198
|
+
timestampUnixSeconds: series.value?.timestampUnixSeconds,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function normalizeObsToolCallRow(row: ObsToolCallRow): ObsToolCallRow | undefined {
|
|
203
|
+
const ratePerSecond = parseRatePerSecond(row.ratePerSecond);
|
|
204
|
+
if (ratePerSecond === undefined) return undefined;
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
toolName: normalizeMetricLabel(row.toolName),
|
|
208
|
+
ratePerSecond,
|
|
209
|
+
timestampUnixSeconds: normalizeOptionalString(row.timestampUnixSeconds),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function normalizeObsToolFailureRow(row: ObsToolFailureRow): ObsToolFailureRow | undefined {
|
|
214
|
+
const ratePerSecond = parseRatePerSecond(row.ratePerSecond);
|
|
215
|
+
if (ratePerSecond === undefined) return undefined;
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
toolName: normalizeMetricLabel(row.toolName),
|
|
219
|
+
errorClass: normalizeMetricLabel(row.errorClass),
|
|
220
|
+
ratePerSecond,
|
|
221
|
+
timestampUnixSeconds: normalizeOptionalString(row.timestampUnixSeconds),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function parseRatePerSecond(value: string | number | undefined): number | undefined {
|
|
226
|
+
if (value === undefined) return undefined;
|
|
227
|
+
|
|
228
|
+
const ratePerSecond = Number(value);
|
|
229
|
+
if (!Number.isFinite(ratePerSecond) || ratePerSecond < 0) return undefined;
|
|
230
|
+
return ratePerSecond;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function normalizeMetricLabel(value: string | undefined): string {
|
|
234
|
+
return normalizeOptionalString(value) ?? "unknown";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function normalizeOptionalString(value: string | undefined): string | undefined {
|
|
238
|
+
const trimmed = value?.trim();
|
|
239
|
+
return trimmed || undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderObsToolCallRow(row: ObsToolCallRow): string {
|
|
243
|
+
return `${row.toolName}: ${formatRatePerSecond(row.ratePerSecond)}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function renderObsToolFailureRow(row: ObsToolFailureRow): string {
|
|
247
|
+
return `${row.toolName} / ${row.errorClass}: ${formatRatePerSecond(row.ratePerSecond)}`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function isObsToolCallRow(row: ObsToolCallRow | undefined): row is ObsToolCallRow {
|
|
251
|
+
return row !== undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isObsToolFailureRow(row: ObsToolFailureRow | undefined): row is ObsToolFailureRow {
|
|
255
|
+
return row !== undefined;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function parseObsToolsRequest(args: string): ObsToolsRequestStatus {
|
|
259
|
+
return isExactObsSubcommandRequest(args, OBS_TOOLS_SUBCOMMAND) ? "tools" : "usage";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function formatRatePerSecond(value: number): string {
|
|
263
|
+
return `${trimTrailingFractionZeros(value.toFixed(4))}/s`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function trimTrailingFractionZeros(value: string): string {
|
|
267
|
+
if (!value.includes(".")) return value;
|
|
268
|
+
|
|
269
|
+
let end = value.length;
|
|
270
|
+
while (end > 0 && value[end - 1] === "0") end -= 1;
|
|
271
|
+
|
|
272
|
+
const withoutTrailingZeros = value.slice(0, end);
|
|
273
|
+
return withoutTrailingZeros.endsWith(".") ? withoutTrailingZeros.slice(0, -1) : withoutTrailingZeros;
|
|
274
|
+
}
|