@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,301 @@
|
|
|
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 { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
|
|
5
|
+
import { getGrafanaHealth, type GrafanaFetch } from "../query/grafana.ts";
|
|
6
|
+
import { completeObsSubcommand, isExactObsSubcommandRequest } from "./obs-args.ts";
|
|
7
|
+
import { loadObsCommandConfig, normalizeObsCommandTimeoutMs, notifyObsCommand } from "./obs-command-support.ts";
|
|
8
|
+
import { appendObsRecoveryHint, formatObsCommandFailure } from "./obs-diagnostics.ts";
|
|
9
|
+
|
|
10
|
+
export interface ObsHealthCommandContext {
|
|
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 type ObsHealthCheckKind = "service" | "datasource";
|
|
19
|
+
export type ObsHealthCheckStatus = "ok" | "failed" | "skipped";
|
|
20
|
+
export type ObsHealthFetch = GrafanaFetch;
|
|
21
|
+
export type ObsHealthConfigLoader = (options: LoadSessionConfigOptions) => Promise<ObservMeConfig>;
|
|
22
|
+
export type ObsHealthProvider = (ctx: ObsHealthCommandContext) => Promise<ObsHealthSnapshot> | ObsHealthSnapshot;
|
|
23
|
+
|
|
24
|
+
export interface ObsHealthCheckResult {
|
|
25
|
+
readonly label: string;
|
|
26
|
+
readonly kind: ObsHealthCheckKind;
|
|
27
|
+
readonly status: ObsHealthCheckStatus;
|
|
28
|
+
readonly detail?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ObsHealthSnapshot {
|
|
32
|
+
readonly timeoutMs: number;
|
|
33
|
+
readonly checks: readonly ObsHealthCheckResult[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ObsHealthSnapshotOptions {
|
|
37
|
+
readonly loadConfig?: ObsHealthConfigLoader;
|
|
38
|
+
readonly fetch?: ObsHealthFetch;
|
|
39
|
+
readonly timeoutMs?: number;
|
|
40
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
41
|
+
readonly configDirName?: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface RegisterObsHealthCommandOptions extends ObsHealthSnapshotOptions {
|
|
45
|
+
readonly getHealth?: ObsHealthProvider;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface HttpHealthTarget {
|
|
49
|
+
readonly label: string;
|
|
50
|
+
readonly kind: ObsHealthCheckKind;
|
|
51
|
+
readonly url: string;
|
|
52
|
+
readonly headers?: Record<string, string>;
|
|
53
|
+
readonly successMode: "ok" | "reachable";
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const OBS_COMMAND_NAME = "obs";
|
|
57
|
+
const OBS_HEALTH_SUBCOMMAND = "health";
|
|
58
|
+
const OBS_HEALTH_CONFIG_NEXT_ACTION = "fix ObservMe config, then rerun /obs health.";
|
|
59
|
+
const OBS_HEALTH_COLLECTOR_NEXT_ACTION = "verify the Collector is running and otlp.endpoint is reachable.";
|
|
60
|
+
const OBS_HEALTH_GRAFANA_NEXT_ACTION = "check query.grafana.url and credentials, then rerun /obs health.";
|
|
61
|
+
const OBS_HEALTH_TEMPO_NEXT_ACTION = "check Grafana credentials and query.grafana.datasourceUids.tempo, then rerun /obs health.";
|
|
62
|
+
const OBS_HEALTH_LOKI_NEXT_ACTION = "check Grafana credentials and query.grafana.datasourceUids.loki, then rerun /obs health.";
|
|
63
|
+
const OBS_HEALTH_METRICS_NEXT_ACTION = "check Grafana credentials and query.grafana.datasourceUids.prometheus, then rerun /obs health.";
|
|
64
|
+
export function registerObsHealthCommand(pi: ExtensionAPI, options: RegisterObsHealthCommandOptions = {}): void {
|
|
65
|
+
const command = new ObsHealthCommand(options);
|
|
66
|
+
|
|
67
|
+
pi.registerCommand(OBS_COMMAND_NAME, {
|
|
68
|
+
description: "Check ObservMe backend health. Usage: /obs health",
|
|
69
|
+
getArgumentCompletions: getObsHealthCommandArgumentCompletions,
|
|
70
|
+
handler: command.handle.bind(command),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function handleObsHealthCommand(
|
|
75
|
+
args: string,
|
|
76
|
+
ctx: ObsHealthCommandContext,
|
|
77
|
+
options: RegisterObsHealthCommandOptions = {},
|
|
78
|
+
): Promise<void> {
|
|
79
|
+
if (!isObsHealthRequest(args)) {
|
|
80
|
+
await notifyObsCommand(ctx, "Usage: /obs health", "warning");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const snapshot = await resolveObsHealthSnapshot(ctx, options);
|
|
86
|
+
await notifyObsCommand(ctx, renderObsHealth(snapshot), resolveObsHealthNotificationType(snapshot));
|
|
87
|
+
} catch (error) {
|
|
88
|
+
await notifyObsCommand(
|
|
89
|
+
ctx,
|
|
90
|
+
formatObsCommandFailure("ObservMe health unavailable", error, {
|
|
91
|
+
subsystem: "Health configuration",
|
|
92
|
+
nextAction: OBS_HEALTH_CONFIG_NEXT_ACTION,
|
|
93
|
+
}),
|
|
94
|
+
"error",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function getObsHealthCommandArgumentCompletions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
100
|
+
return completeObsSubcommand(prefix, OBS_HEALTH_SUBCOMMAND);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function getObsHealthSnapshot(
|
|
104
|
+
ctx: ObsHealthCommandContext,
|
|
105
|
+
options: ObsHealthSnapshotOptions = {},
|
|
106
|
+
): Promise<ObsHealthSnapshot> {
|
|
107
|
+
const config = await loadObsHealthConfig(ctx, options);
|
|
108
|
+
const timeoutMs = resolveObsHealthTimeout(config, options);
|
|
109
|
+
const collectorFetcher = resolveCollectorHealthFetch(options.fetch);
|
|
110
|
+
const [collectorCheck, grafanaHealth] = await Promise.all([
|
|
111
|
+
checkCollectorReachability(config, collectorFetcher, timeoutMs),
|
|
112
|
+
getGrafanaHealth(config, { fetch: options.fetch, timeoutMs }),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
return { timeoutMs, checks: [collectorCheck, ...grafanaHealth.checks] };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function renderObsHealth(snapshot: ObsHealthSnapshot): string {
|
|
119
|
+
return snapshot.checks.map(renderObsHealthCheck).join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
class ObsHealthCommand {
|
|
123
|
+
readonly #options: RegisterObsHealthCommandOptions;
|
|
124
|
+
|
|
125
|
+
constructor(options: RegisterObsHealthCommandOptions) {
|
|
126
|
+
this.#options = options;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
async handle(args: string, ctx: ObsHealthCommandContext): Promise<void> {
|
|
130
|
+
await handleObsHealthCommand(args, ctx, this.#options);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function resolveObsHealthSnapshot(
|
|
135
|
+
ctx: ObsHealthCommandContext,
|
|
136
|
+
options: RegisterObsHealthCommandOptions,
|
|
137
|
+
): Promise<ObsHealthSnapshot> {
|
|
138
|
+
if (options.getHealth) return options.getHealth(ctx);
|
|
139
|
+
return getObsHealthSnapshot(ctx, options);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function loadObsHealthConfig(
|
|
143
|
+
ctx: ObsHealthCommandContext,
|
|
144
|
+
options: ObsHealthSnapshotOptions,
|
|
145
|
+
): Promise<ObservMeConfig> {
|
|
146
|
+
return loadObsCommandConfig(ctx, options);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function checkCollectorReachability(
|
|
150
|
+
config: ObservMeConfig,
|
|
151
|
+
fetcher: ObsHealthFetch,
|
|
152
|
+
timeoutMs: number,
|
|
153
|
+
): Promise<ObsHealthCheckResult> {
|
|
154
|
+
const target = {
|
|
155
|
+
label: "Collector",
|
|
156
|
+
kind: "service" as const,
|
|
157
|
+
url: config.otlp.endpoint,
|
|
158
|
+
headers: filterConfiguredHeaders(config.otlp.headers),
|
|
159
|
+
successMode: "reachable" as const,
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
return checkHttpHealthTarget(target, fetcher, timeoutMs);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function checkHttpHealthTarget(
|
|
166
|
+
target: HttpHealthTarget,
|
|
167
|
+
fetcher: ObsHealthFetch,
|
|
168
|
+
timeoutMs: number,
|
|
169
|
+
): Promise<ObsHealthCheckResult> {
|
|
170
|
+
try {
|
|
171
|
+
return responseToHealthResult(target, await fetchWithTimeout(target, fetcher, timeoutMs));
|
|
172
|
+
} catch (error) {
|
|
173
|
+
return failedHealthResult(target.label, target.kind, error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function fetchWithTimeout(target: HttpHealthTarget, fetcher: ObsHealthFetch, timeoutMs: number): Promise<Response> {
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
return await fetcher(target.url, {
|
|
183
|
+
method: "GET",
|
|
184
|
+
headers: target.headers,
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
});
|
|
187
|
+
} finally {
|
|
188
|
+
clearTimeout(timeout);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function filterConfiguredHeaders(headers: Record<string, string>): Record<string, string> | undefined {
|
|
193
|
+
const filtered: Record<string, string> = {};
|
|
194
|
+
|
|
195
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
196
|
+
const trimmed = value.trim();
|
|
197
|
+
if (trimmed && !hasUnresolvedEnvironmentPlaceholder(trimmed)) filtered[name] = trimmed;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return Object.keys(filtered).length > 0 ? filtered : undefined;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function hasUnresolvedEnvironmentPlaceholder(value: string): boolean {
|
|
204
|
+
return /\$\{[A-Z0-9_]+\}/u.test(value);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function responseToHealthResult(target: HttpHealthTarget, response: Response): ObsHealthCheckResult {
|
|
208
|
+
if (responseMatchesSuccessMode(target, response)) return { label: target.label, kind: target.kind, status: "ok" };
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
label: target.label,
|
|
212
|
+
kind: target.kind,
|
|
213
|
+
status: "failed",
|
|
214
|
+
detail: formatHttpFailure(response),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function responseMatchesSuccessMode(target: HttpHealthTarget, response: Response): boolean {
|
|
219
|
+
if (target.successMode === "reachable") return response.status < 500;
|
|
220
|
+
return response.ok;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function failedHealthResult(label: string, kind: ObsHealthCheckKind, error: unknown): ObsHealthCheckResult {
|
|
224
|
+
return {
|
|
225
|
+
label,
|
|
226
|
+
kind,
|
|
227
|
+
status: "failed",
|
|
228
|
+
detail: formatHealthFailure(error),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function renderObsHealthCheck(check: ObsHealthCheckResult): string {
|
|
233
|
+
const status = formatObsHealthStatus(check);
|
|
234
|
+
const detail = formatObsHealthDetail(check);
|
|
235
|
+
const detailText = detail ? ` (${detail})` : "";
|
|
236
|
+
return `${check.label}: ${status}${detailText}`;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function formatObsHealthStatus(check: ObsHealthCheckResult): string {
|
|
240
|
+
if (check.status === "ok") return check.kind === "datasource" ? "ok" : "reachable";
|
|
241
|
+
if (check.status === "skipped") return "skipped";
|
|
242
|
+
return check.kind === "datasource" ? "failed" : "unreachable";
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatObsHealthDetail(check: ObsHealthCheckResult): string | undefined {
|
|
246
|
+
if (!check.detail) return undefined;
|
|
247
|
+
if (check.status !== "failed") return check.detail;
|
|
248
|
+
return appendObsRecoveryHint(check.detail, resolveObsHealthNextAction(check));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function resolveObsHealthNextAction(check: ObsHealthCheckResult): string {
|
|
252
|
+
if (check.label === "Collector") return OBS_HEALTH_COLLECTOR_NEXT_ACTION;
|
|
253
|
+
if (check.label === "Grafana") return OBS_HEALTH_GRAFANA_NEXT_ACTION;
|
|
254
|
+
if (check.label === "Tempo datasource") return OBS_HEALTH_TEMPO_NEXT_ACTION;
|
|
255
|
+
if (check.label === "Loki datasource") return OBS_HEALTH_LOKI_NEXT_ACTION;
|
|
256
|
+
if (check.label === "Metrics datasource") return OBS_HEALTH_METRICS_NEXT_ACTION;
|
|
257
|
+
return OBS_HEALTH_CONFIG_NEXT_ACTION;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function resolveObsHealthNotificationType(snapshot: ObsHealthSnapshot): "info" | "warning" {
|
|
261
|
+
return snapshot.checks.some(check => check.status === "failed") ? "warning" : "info";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function resolveObsHealthTimeout(config: ObservMeConfig, options: ObsHealthSnapshotOptions): number {
|
|
265
|
+
return normalizeObsCommandTimeoutMs(options.timeoutMs, config.query.timeoutMs, 1);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
function resolveCollectorHealthFetch(fetcher: ObsHealthFetch | undefined): ObsHealthFetch {
|
|
270
|
+
return fetcher ?? globalThis.fetch.bind(globalThis);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function isObsHealthRequest(args: string): boolean {
|
|
274
|
+
return isExactObsSubcommandRequest(args, OBS_HEALTH_SUBCOMMAND);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function formatHttpFailure(response: Response): string {
|
|
278
|
+
return formatHttpStatus(response);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function formatHttpStatus(response: Response): string {
|
|
282
|
+
const statusText = response.statusText.trim();
|
|
283
|
+
return statusText ? `HTTP ${response.status} ${statusText}` : `HTTP ${response.status}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function formatHealthFailure(error: unknown): string {
|
|
287
|
+
if (isAbortError(error)) return "timed out";
|
|
288
|
+
return formatError(error);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function isAbortError(error: unknown): boolean {
|
|
292
|
+
return isNamedError(error) && error.name === "AbortError";
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function isNamedError(error: unknown): error is { name: string } {
|
|
296
|
+
return typeof error === "object" && error !== null && "name" in error && typeof error.name === "string";
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function formatError(error: unknown): string {
|
|
300
|
+
return sanitizeDiagnosticText(readDiagnosticMessage(error));
|
|
301
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type {
|
|
3
|
+
ObsTraceCommandContext,
|
|
4
|
+
ObsTraceProvider,
|
|
5
|
+
ObsTraceRequest,
|
|
6
|
+
ObsTraceSnapshot,
|
|
7
|
+
ObsTraceSnapshotOptions,
|
|
8
|
+
} from "./obs-trace.ts";
|
|
9
|
+
import { completeObsSubcommand, obsUsageWithError } from "./obs-args.ts";
|
|
10
|
+
import { notifyObsCommand } from "./obs-command-support.ts";
|
|
11
|
+
import { formatObsCommandFailure } from "./obs-diagnostics.ts";
|
|
12
|
+
import {
|
|
13
|
+
getObsTraceSnapshot,
|
|
14
|
+
parseObsTraceArgsForSubcommand,
|
|
15
|
+
renderObsTraceWithTitle,
|
|
16
|
+
resolveObsTraceDiagnostic,
|
|
17
|
+
} from "./obs-trace.ts";
|
|
18
|
+
|
|
19
|
+
export type ObsLinkCommandContext = ObsTraceCommandContext;
|
|
20
|
+
export type ObsLinkProvider = ObsTraceProvider;
|
|
21
|
+
|
|
22
|
+
export interface RegisterObsLinkCommandOptions extends ObsTraceSnapshotOptions {
|
|
23
|
+
readonly getLink?: ObsLinkProvider;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const OBS_COMMAND_NAME = "obs";
|
|
27
|
+
const OBS_LINK_SUBCOMMAND = "link";
|
|
28
|
+
const OBS_LINK_USAGE = "Usage: /obs link [--last-turn|--session <session-id>]";
|
|
29
|
+
|
|
30
|
+
export function registerObsLinkCommand(pi: ExtensionAPI, options: RegisterObsLinkCommandOptions = {}): void {
|
|
31
|
+
const command = new ObsLinkCommand(options);
|
|
32
|
+
|
|
33
|
+
pi.registerCommand(OBS_COMMAND_NAME, {
|
|
34
|
+
description: "Show an ObservMe Grafana link. Usage: /obs link [--last-turn|--session <session-id>]",
|
|
35
|
+
getArgumentCompletions: getObsLinkCommandArgumentCompletions,
|
|
36
|
+
handler: command.handle.bind(command),
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function handleObsLinkCommand(
|
|
41
|
+
args: string,
|
|
42
|
+
ctx: ObsLinkCommandContext,
|
|
43
|
+
options: RegisterObsLinkCommandOptions = {},
|
|
44
|
+
): Promise<void> {
|
|
45
|
+
const parsed = parseObsTraceArgsForSubcommand(args, OBS_LINK_SUBCOMMAND);
|
|
46
|
+
|
|
47
|
+
if (!parsed.request) {
|
|
48
|
+
await notifyObsCommand(ctx, obsUsageWithError(OBS_LINK_USAGE, parsed.error), "warning");
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const request = parsed.request;
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const snapshot = await resolveObsLinkSnapshot(ctx, request, options);
|
|
56
|
+
await notifyObsCommand(ctx, renderObsLink(snapshot), "info");
|
|
57
|
+
} catch (error) {
|
|
58
|
+
await notifyObsCommand(ctx, formatObsCommandFailure("ObservMe link unavailable", error, resolveObsTraceDiagnostic(error)), "error");
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function getObsLinkCommandArgumentCompletions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
63
|
+
return completeObsSubcommand(prefix, OBS_LINK_SUBCOMMAND);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function renderObsLink(snapshot: ObsTraceSnapshot): string {
|
|
67
|
+
return renderObsTraceWithTitle(snapshot, "Grafana link");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
class ObsLinkCommand {
|
|
71
|
+
readonly #options: RegisterObsLinkCommandOptions;
|
|
72
|
+
|
|
73
|
+
constructor(options: RegisterObsLinkCommandOptions) {
|
|
74
|
+
this.#options = options;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async handle(args: string, ctx: ObsLinkCommandContext): Promise<void> {
|
|
78
|
+
await handleObsLinkCommand(args, ctx, this.#options);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function resolveObsLinkSnapshot(
|
|
83
|
+
ctx: ObsLinkCommandContext,
|
|
84
|
+
request: ObsTraceRequest,
|
|
85
|
+
options: RegisterObsLinkCommandOptions,
|
|
86
|
+
): Promise<ObsTraceSnapshot> {
|
|
87
|
+
if (options.getLink) return options.getLink(ctx, request);
|
|
88
|
+
return getObsTraceSnapshot(ctx, request, options);
|
|
89
|
+
}
|
|
90
|
+
|
|
@@ -0,0 +1,194 @@
|
|
|
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 { LokiFetch } from "../query/loki.ts";
|
|
5
|
+
import { createLokiQueryClient } from "../query/loki.ts";
|
|
6
|
+
import { normalizeObservMeSessionId } from "../safety/sensitive-input.ts";
|
|
7
|
+
import { completeObsSubcommand, isExactObsSubcommandRequest } from "./obs-args.ts";
|
|
8
|
+
import { loadObsCommandConfig, notifyObsCommand } from "./obs-command-support.ts";
|
|
9
|
+
import { appendObsRecoveryHint, formatObsCommandFailure, readObsDiagnosticMessage, type ObsCommandRecoveryHint } from "./obs-diagnostics.ts";
|
|
10
|
+
import type { ObsLokiLogSummaryRow, ObsLokiTimeRangeOptions } from "./obs-loki-summary.ts";
|
|
11
|
+
import {
|
|
12
|
+
createRecentObsLokiTimeRange,
|
|
13
|
+
formatObsLokiWindow,
|
|
14
|
+
normalizeObsLokiMaxLogs,
|
|
15
|
+
renderObsLokiLogSummary,
|
|
16
|
+
toObsLokiLogSummaryRow,
|
|
17
|
+
} from "./obs-loki-summary.ts";
|
|
18
|
+
import type { ObsSessionSnapshot } from "./obs-session.ts";
|
|
19
|
+
import { getLocalObsSessionSnapshot } from "./obs-session.ts";
|
|
20
|
+
|
|
21
|
+
export interface ObsLogsCommandContext {
|
|
22
|
+
readonly cwd?: string;
|
|
23
|
+
readonly ui: {
|
|
24
|
+
notify: (message: string, type?: "info" | "warning" | "error") => Promise<void> | void;
|
|
25
|
+
};
|
|
26
|
+
readonly isProjectTrusted?: () => boolean | Promise<boolean>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type ObsLogsSessionSnapshot = Pick<ObsSessionSnapshot, "sessionId">;
|
|
30
|
+
export type ObsLogsSessionProvider = (ctx: ObsLogsCommandContext) => Promise<ObsLogsSessionSnapshot> | ObsLogsSessionSnapshot;
|
|
31
|
+
|
|
32
|
+
export interface ObsLogsSnapshot {
|
|
33
|
+
readonly sessionId: string;
|
|
34
|
+
readonly window: string;
|
|
35
|
+
readonly query: string;
|
|
36
|
+
readonly maxLogs: number;
|
|
37
|
+
readonly logs: readonly ObsLokiLogSummaryRow[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type ObsLogsConfigLoader = (options: LoadSessionConfigOptions) => Promise<ObservMeConfig>;
|
|
41
|
+
export type ObsLogsProvider = (ctx: ObsLogsCommandContext) => Promise<ObsLogsSnapshot> | ObsLogsSnapshot;
|
|
42
|
+
|
|
43
|
+
export interface ObsLogsSnapshotOptions extends ObsLokiTimeRangeOptions {
|
|
44
|
+
readonly loadConfig?: ObsLogsConfigLoader;
|
|
45
|
+
readonly fetch?: LokiFetch;
|
|
46
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
47
|
+
readonly configDirName?: string;
|
|
48
|
+
readonly getSession?: ObsLogsSessionProvider;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RegisterObsLogsCommandOptions extends ObsLogsSnapshotOptions {
|
|
52
|
+
readonly getLogs?: ObsLogsProvider;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const OBS_LOGS_LOGQL_PREFIX = '{service_name="observme-pi-extension", event_category!~"llm_content|tool_content", pi_session_id=';
|
|
56
|
+
|
|
57
|
+
const OBS_COMMAND_NAME = "obs";
|
|
58
|
+
const OBS_LOGS_SUBCOMMAND = "logs";
|
|
59
|
+
const OBS_LOGS_USAGE = "Usage: /obs logs";
|
|
60
|
+
const OBS_LOGS_LOKI_ERROR_NEXT_ACTION = "run /obs health and verify query.grafana.url, Grafana credentials, the Loki datasource UID, and service labels.";
|
|
61
|
+
const OBS_LOGS_SESSION_ERROR_NEXT_ACTION = "run /obs session to confirm a current session before /obs logs.";
|
|
62
|
+
const OBS_LOGS_NO_LOGS_NEXT_ACTION = "wait for telemetry export, then verify Loki labels and datasource with /obs health.";
|
|
63
|
+
const logQlBackslash = String.fromCodePoint(92);
|
|
64
|
+
|
|
65
|
+
export function registerObsLogsCommand(pi: ExtensionAPI, options: RegisterObsLogsCommandOptions = {}): void {
|
|
66
|
+
const command = new ObsLogsCommand(options);
|
|
67
|
+
|
|
68
|
+
pi.registerCommand(OBS_COMMAND_NAME, {
|
|
69
|
+
description: "Show current-session ObservMe logs from Loki. Usage: /obs logs",
|
|
70
|
+
getArgumentCompletions: getObsLogsCommandArgumentCompletions,
|
|
71
|
+
handler: command.handle.bind(command),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function handleObsLogsCommand(
|
|
76
|
+
args: string,
|
|
77
|
+
ctx: ObsLogsCommandContext,
|
|
78
|
+
options: RegisterObsLogsCommandOptions = {},
|
|
79
|
+
): Promise<void> {
|
|
80
|
+
if (!isObsLogsRequest(args)) {
|
|
81
|
+
await notifyObsCommand(ctx, OBS_LOGS_USAGE, "warning");
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const snapshot = await resolveObsLogsSnapshot(ctx, options);
|
|
87
|
+
await notifyObsCommand(ctx, renderObsLogs(snapshot), "info");
|
|
88
|
+
} catch (error) {
|
|
89
|
+
await notifyObsCommand(ctx, formatObsCommandFailure("ObservMe logs unavailable", error, resolveObsLogsDiagnostic(error)), "error");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getObsLogsCommandArgumentCompletions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
94
|
+
return completeObsSubcommand(prefix, OBS_LOGS_SUBCOMMAND);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function getObsLogsSnapshot(
|
|
98
|
+
ctx: ObsLogsCommandContext,
|
|
99
|
+
options: ObsLogsSnapshotOptions = {},
|
|
100
|
+
): Promise<ObsLogsSnapshot> {
|
|
101
|
+
const sessionId = await resolveObsLogsSessionId(ctx, options);
|
|
102
|
+
const config = await loadObsLogsConfig(ctx, options);
|
|
103
|
+
const maxLogs = normalizeObsLokiMaxLogs(config.query.maxLogs);
|
|
104
|
+
const query = buildObsLogsLogQl(sessionId);
|
|
105
|
+
const logs = await queryObsLogs(config, query, options);
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
sessionId,
|
|
109
|
+
window: formatObsLokiWindow(options),
|
|
110
|
+
query,
|
|
111
|
+
maxLogs,
|
|
112
|
+
logs: logs.slice(0, maxLogs).map(toObsLokiLogSummaryRow),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function buildObsLogsLogQl(sessionId: string): string {
|
|
117
|
+
return `${OBS_LOGS_LOGQL_PREFIX}"${escapeLogQlString(normalizeObsLogsSessionId(sessionId))}"}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function renderObsLogs(snapshot: ObsLogsSnapshot): string {
|
|
121
|
+
return renderObsLokiLogSummary({
|
|
122
|
+
title: `Session logs for ${snapshot.sessionId}`,
|
|
123
|
+
window: snapshot.window,
|
|
124
|
+
maxLogs: snapshot.maxLogs,
|
|
125
|
+
rows: snapshot.logs,
|
|
126
|
+
emptyMessage: appendObsRecoveryHint("No session logs found.", OBS_LOGS_NO_LOGS_NEXT_ACTION),
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class ObsLogsCommand {
|
|
131
|
+
readonly #options: RegisterObsLogsCommandOptions;
|
|
132
|
+
|
|
133
|
+
constructor(options: RegisterObsLogsCommandOptions) {
|
|
134
|
+
this.#options = options;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async handle(args: string, ctx: ObsLogsCommandContext): Promise<void> {
|
|
138
|
+
await handleObsLogsCommand(args, ctx, this.#options);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function resolveObsLogsSnapshot(
|
|
143
|
+
ctx: ObsLogsCommandContext,
|
|
144
|
+
options: RegisterObsLogsCommandOptions,
|
|
145
|
+
): Promise<ObsLogsSnapshot> {
|
|
146
|
+
if (options.getLogs) return options.getLogs(ctx);
|
|
147
|
+
return getObsLogsSnapshot(ctx, options);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function resolveObsLogsSessionId(ctx: ObsLogsCommandContext, options: ObsLogsSnapshotOptions): Promise<string> {
|
|
151
|
+
const session = await resolveObsLogsSession(ctx, options);
|
|
152
|
+
return normalizeObsLogsSessionId(session.sessionId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function resolveObsLogsSession(
|
|
156
|
+
ctx: ObsLogsCommandContext,
|
|
157
|
+
options: ObsLogsSnapshotOptions,
|
|
158
|
+
): Promise<ObsLogsSessionSnapshot> {
|
|
159
|
+
if (options.getSession) return options.getSession(ctx);
|
|
160
|
+
return getLocalObsSessionSnapshot();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function loadObsLogsConfig(ctx: ObsLogsCommandContext, options: ObsLogsSnapshotOptions): Promise<ObservMeConfig> {
|
|
164
|
+
return loadObsCommandConfig(ctx, options);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function queryObsLogs(config: ObservMeConfig, query: string, options: ObsLogsSnapshotOptions) {
|
|
168
|
+
const client = createLokiQueryClient(config, { fetch: options.fetch });
|
|
169
|
+
return client.queryLoki(query, createRecentObsLokiTimeRange(options));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeObsLogsSessionId(value: string | undefined): string {
|
|
173
|
+
return normalizeObservMeSessionId(value, { emptyMessage: "No current ObservMe session id is available." });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function escapeLogQlString(value: string): string {
|
|
177
|
+
return value.replaceAll(logQlBackslash, String.raw`\\`).replaceAll('"', String.raw`\"`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function isObsLogsRequest(args: string): boolean {
|
|
181
|
+
return isExactObsSubcommandRequest(args, OBS_LOGS_SUBCOMMAND);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolveObsLogsDiagnostic(error: unknown): ObsCommandRecoveryHint {
|
|
185
|
+
if (isObsLogsSessionErrorMessage(readObsDiagnosticMessage(error))) {
|
|
186
|
+
return { subsystem: "Session", nextAction: OBS_LOGS_SESSION_ERROR_NEXT_ACTION };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return { subsystem: "Loki", nextAction: OBS_LOGS_LOKI_ERROR_NEXT_ACTION };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function isObsLogsSessionErrorMessage(message: string): boolean {
|
|
193
|
+
return /current ObservMe session id|Unsafe ObservMe session id/u.test(message);
|
|
194
|
+
}
|