@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,43 @@
|
|
|
1
|
+
import type { LoadSessionConfigOptions } from "../config/load-config.ts";
|
|
2
|
+
import { loadSessionConfig } from "../config/load-config.ts";
|
|
3
|
+
import type { ObservMeConfig } from "../config/schema.ts";
|
|
4
|
+
|
|
5
|
+
export type ObsCommandNotificationType = "info" | "warning" | "error";
|
|
6
|
+
|
|
7
|
+
export interface ObsCommandContext {
|
|
8
|
+
readonly cwd?: string;
|
|
9
|
+
readonly ui: {
|
|
10
|
+
notify: (message: string, type?: ObsCommandNotificationType) => Promise<void> | void;
|
|
11
|
+
};
|
|
12
|
+
readonly isProjectTrusted?: () => boolean | Promise<boolean>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type ObsCommandConfigLoader = (options: LoadSessionConfigOptions) => Promise<ObservMeConfig>;
|
|
16
|
+
|
|
17
|
+
export interface ObsCommandConfigOptions {
|
|
18
|
+
readonly loadConfig?: ObsCommandConfigLoader;
|
|
19
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
20
|
+
readonly configDirName?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loadObsCommandConfig(
|
|
24
|
+
ctx: ObsCommandContext,
|
|
25
|
+
options: ObsCommandConfigOptions,
|
|
26
|
+
): Promise<ObservMeConfig> {
|
|
27
|
+
const loadConfig = options.loadConfig ?? loadSessionConfig;
|
|
28
|
+
return loadConfig({ ctx, cwd: ctx.cwd, configDirName: options.configDirName, env: options.env });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function notifyObsCommand(
|
|
32
|
+
ctx: ObsCommandContext,
|
|
33
|
+
message: string,
|
|
34
|
+
type: ObsCommandNotificationType,
|
|
35
|
+
): Promise<void> {
|
|
36
|
+
await ctx.ui?.notify?.(message, type);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function normalizeObsCommandTimeoutMs(value: number | undefined, fallback: number, invalidFallback = fallback): number {
|
|
40
|
+
const timeoutMs = value ?? fallback;
|
|
41
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < 1) return invalidFallback;
|
|
42
|
+
return Math.trunc(timeoutMs);
|
|
43
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
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, parseObsSubcommandArgs } 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 ObsCostCommandContext {
|
|
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 ObsCostRow {
|
|
19
|
+
readonly model: string;
|
|
20
|
+
readonly provider: string;
|
|
21
|
+
readonly costUsd: number;
|
|
22
|
+
readonly timestampUnixSeconds?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ObsCostSnapshot {
|
|
26
|
+
readonly window: "24h";
|
|
27
|
+
readonly query: string;
|
|
28
|
+
readonly rows: readonly ObsCostRow[];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ObsCostConfigLoader = (options: LoadSessionConfigOptions) => Promise<ObservMeConfig>;
|
|
32
|
+
export type ObsCostProvider = (ctx: ObsCostCommandContext) => Promise<ObsCostSnapshot> | ObsCostSnapshot;
|
|
33
|
+
|
|
34
|
+
export interface ObsCostSnapshotOptions {
|
|
35
|
+
readonly loadConfig?: ObsCostConfigLoader;
|
|
36
|
+
readonly fetch?: PrometheusFetch;
|
|
37
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
38
|
+
readonly configDirName?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RegisterObsCostCommandOptions extends ObsCostSnapshotOptions {
|
|
42
|
+
readonly getCost?: ObsCostProvider;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const OBS_COST_AGGREGATE_PROMQL = "sum(increase(observme_llm_cost_usd_total[24h])) by (model, provider)";
|
|
46
|
+
|
|
47
|
+
const OBS_COMMAND_NAME = "obs";
|
|
48
|
+
const OBS_COST_SUBCOMMAND = "cost";
|
|
49
|
+
const OBS_COST_WINDOW = "24h";
|
|
50
|
+
const OBS_COST_ERROR_NEXT_ACTION = "run /obs health and verify query.grafana.url, Grafana credentials, and the Metrics datasource UID.";
|
|
51
|
+
const OBS_COST_NO_METRICS_NEXT_ACTION = "generate LLM usage, then verify the Metrics datasource with /obs health.";
|
|
52
|
+
|
|
53
|
+
type ObsCostRequestStatus = "cost" | "session-disabled" | "usage";
|
|
54
|
+
|
|
55
|
+
export function registerObsCostCommand(pi: ExtensionAPI, options: RegisterObsCostCommandOptions = {}): void {
|
|
56
|
+
const command = new ObsCostCommand(options);
|
|
57
|
+
|
|
58
|
+
pi.registerCommand(OBS_COMMAND_NAME, {
|
|
59
|
+
description: "Show aggregate ObservMe LLM cost. Usage: /obs cost",
|
|
60
|
+
getArgumentCompletions: getObsCostCommandArgumentCompletions,
|
|
61
|
+
handler: command.handle.bind(command),
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function handleObsCostCommand(
|
|
66
|
+
args: string,
|
|
67
|
+
ctx: ObsCostCommandContext,
|
|
68
|
+
options: RegisterObsCostCommandOptions = {},
|
|
69
|
+
): Promise<void> {
|
|
70
|
+
const requestStatus = parseObsCostRequest(args);
|
|
71
|
+
|
|
72
|
+
if (requestStatus === "usage") {
|
|
73
|
+
await notifyObsCommand(ctx, "Usage: /obs cost", "warning");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (requestStatus === "session-disabled") {
|
|
78
|
+
await notifyObsCommand(ctx, "Session-scoped Prometheus cost queries are disabled by default. Usage: /obs cost", "warning");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const snapshot = await resolveObsCostSnapshot(ctx, options);
|
|
84
|
+
await notifyObsCommand(ctx, renderObsCost(snapshot), "info");
|
|
85
|
+
} catch (error) {
|
|
86
|
+
await notifyObsCommand(
|
|
87
|
+
ctx,
|
|
88
|
+
formatObsCommandFailure("ObservMe cost unavailable", error, {
|
|
89
|
+
subsystem: "Prometheus",
|
|
90
|
+
nextAction: OBS_COST_ERROR_NEXT_ACTION,
|
|
91
|
+
}),
|
|
92
|
+
"error",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getObsCostCommandArgumentCompletions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
98
|
+
return completeObsSubcommand(prefix, OBS_COST_SUBCOMMAND);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function getObsCostSnapshot(
|
|
102
|
+
ctx: ObsCostCommandContext,
|
|
103
|
+
options: ObsCostSnapshotOptions = {},
|
|
104
|
+
): Promise<ObsCostSnapshot> {
|
|
105
|
+
const config = await loadObsCostConfig(ctx, options);
|
|
106
|
+
const result = await queryObsCost(config, options);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
window: OBS_COST_WINDOW,
|
|
110
|
+
query: OBS_COST_AGGREGATE_PROMQL,
|
|
111
|
+
rows: result.series.map(toObsCostRow).filter(isObsCostRow),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function renderObsCost(snapshot: ObsCostSnapshot): string {
|
|
116
|
+
const rows = snapshot.rows.map(normalizeObsCostRow).filter(isObsCostRow);
|
|
117
|
+
const lines = [`Cost by model/provider (last ${snapshot.window})`];
|
|
118
|
+
|
|
119
|
+
if (rows.length === 0) {
|
|
120
|
+
lines.push(appendObsRecoveryHint("No cost metrics found.", OBS_COST_NO_METRICS_NEXT_ACTION));
|
|
121
|
+
return lines.join("\n");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
lines.push(...rows.map(renderObsCostRow), `Total: ${formatUsd(sumObsCostRows(rows))}`);
|
|
125
|
+
return lines.join("\n");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
class ObsCostCommand {
|
|
129
|
+
readonly #options: RegisterObsCostCommandOptions;
|
|
130
|
+
|
|
131
|
+
constructor(options: RegisterObsCostCommandOptions) {
|
|
132
|
+
this.#options = options;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async handle(args: string, ctx: ObsCostCommandContext): Promise<void> {
|
|
136
|
+
await handleObsCostCommand(args, ctx, this.#options);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function resolveObsCostSnapshot(
|
|
141
|
+
ctx: ObsCostCommandContext,
|
|
142
|
+
options: RegisterObsCostCommandOptions,
|
|
143
|
+
): Promise<ObsCostSnapshot> {
|
|
144
|
+
if (options.getCost) return options.getCost(ctx);
|
|
145
|
+
return getObsCostSnapshot(ctx, options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function loadObsCostConfig(ctx: ObsCostCommandContext, options: ObsCostSnapshotOptions): Promise<ObservMeConfig> {
|
|
149
|
+
return loadObsCommandConfig(ctx, options);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function queryObsCost(config: ObservMeConfig, options: ObsCostSnapshotOptions): Promise<QueryResult> {
|
|
153
|
+
const client = createPrometheusQueryClient(config, { fetch: options.fetch });
|
|
154
|
+
return client.queryPrometheus(OBS_COST_AGGREGATE_PROMQL, undefined, { resultLimit: "metricSeries" });
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function toObsCostRow(series: PrometheusMetricSeries): ObsCostRow | undefined {
|
|
158
|
+
const costUsd = parseCostUsd(series.value?.value);
|
|
159
|
+
if (costUsd === undefined) return undefined;
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
model: normalizeMetricLabel(series.metric.model),
|
|
163
|
+
provider: normalizeMetricLabel(series.metric.provider),
|
|
164
|
+
costUsd,
|
|
165
|
+
timestampUnixSeconds: series.value?.timestampUnixSeconds,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizeObsCostRow(row: ObsCostRow): ObsCostRow | undefined {
|
|
170
|
+
const costUsd = parseCostUsd(row.costUsd);
|
|
171
|
+
if (costUsd === undefined) return undefined;
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
model: normalizeMetricLabel(row.model),
|
|
175
|
+
provider: normalizeMetricLabel(row.provider),
|
|
176
|
+
costUsd,
|
|
177
|
+
timestampUnixSeconds: normalizeOptionalString(row.timestampUnixSeconds),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseCostUsd(value: string | number | undefined): number | undefined {
|
|
182
|
+
if (value === undefined) return undefined;
|
|
183
|
+
|
|
184
|
+
const costUsd = Number(value);
|
|
185
|
+
if (!Number.isFinite(costUsd) || costUsd < 0) return undefined;
|
|
186
|
+
return costUsd;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function normalizeMetricLabel(value: string | undefined): string {
|
|
190
|
+
return normalizeOptionalString(value) ?? "unknown";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function normalizeOptionalString(value: string | undefined): string | undefined {
|
|
194
|
+
const trimmed = value?.trim();
|
|
195
|
+
return trimmed || undefined;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function renderObsCostRow(row: ObsCostRow): string {
|
|
199
|
+
return `${row.model} / ${row.provider}: ${formatUsd(row.costUsd)}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function sumObsCostRows(rows: readonly ObsCostRow[]): number {
|
|
203
|
+
return rows.reduce((total, row) => sumObsCostRow(total, row), 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function sumObsCostRow(total: number, row: ObsCostRow): number {
|
|
207
|
+
return total + row.costUsd;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isObsCostRow(row: ObsCostRow | undefined): row is ObsCostRow {
|
|
211
|
+
return row !== undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function parseObsCostRequest(args: string): ObsCostRequestStatus {
|
|
215
|
+
const parsed = parseObsSubcommandArgs(args, OBS_COST_SUBCOMMAND);
|
|
216
|
+
if (!parsed.matched) return "usage";
|
|
217
|
+
if (parsed.values.some(isSessionScopeCostToken)) return "session-disabled";
|
|
218
|
+
return parsed.values.length === 0 ? "cost" : "usage";
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isSessionScopeCostToken(token: string): boolean {
|
|
222
|
+
const normalizedToken = token.toLowerCase();
|
|
223
|
+
return normalizedToken === "--session" || normalizedToken.startsWith("--session=") || normalizedToken === "--current-session";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function formatUsd(value: number): string {
|
|
227
|
+
return `$${value.toFixed(2)}`;
|
|
228
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
|
|
2
|
+
|
|
3
|
+
export interface ObsCommandRecoveryHint {
|
|
4
|
+
readonly subsystem: string;
|
|
5
|
+
readonly nextAction: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatObsCommandFailure(prefix: string, error: unknown, hint: ObsCommandRecoveryHint): string {
|
|
9
|
+
return `${prefix}: ${hint.subsystem}: ${sanitizeObsDiagnosticText(readObsDiagnosticMessage(error))} Next: ${hint.nextAction}`;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function appendObsRecoveryHint(message: string, nextAction: string): string {
|
|
13
|
+
return `${sanitizeObsDiagnosticText(message)} Next: ${nextAction}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function readObsDiagnosticMessage(error: unknown): string {
|
|
17
|
+
return readDiagnosticMessage(error);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function sanitizeObsDiagnosticText(message: string): string {
|
|
21
|
+
return sanitizeDiagnosticText(message);
|
|
22
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
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 type { ObsLokiLogSummaryRow, ObsLokiTimeRangeOptions } from "./obs-loki-summary.ts";
|
|
7
|
+
import { completeObsSubcommand, isExactObsSubcommandRequest } from "./obs-args.ts";
|
|
8
|
+
import { loadObsCommandConfig, notifyObsCommand } from "./obs-command-support.ts";
|
|
9
|
+
import { appendObsRecoveryHint, formatObsCommandFailure } from "./obs-diagnostics.ts";
|
|
10
|
+
import {
|
|
11
|
+
createRecentObsLokiTimeRange,
|
|
12
|
+
formatObsLokiWindow,
|
|
13
|
+
normalizeObsLokiMaxLogs,
|
|
14
|
+
renderObsLokiLogSummary,
|
|
15
|
+
toObsLokiLogSummaryRow,
|
|
16
|
+
} from "./obs-loki-summary.ts";
|
|
17
|
+
|
|
18
|
+
export interface ObsErrorsCommandContext {
|
|
19
|
+
readonly cwd?: string;
|
|
20
|
+
readonly ui: {
|
|
21
|
+
notify: (message: string, type?: "info" | "warning" | "error") => Promise<void> | void;
|
|
22
|
+
};
|
|
23
|
+
readonly isProjectTrusted?: () => boolean | Promise<boolean>;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ObsErrorsSnapshot {
|
|
27
|
+
readonly window: string;
|
|
28
|
+
readonly query: string;
|
|
29
|
+
readonly maxLogs: number;
|
|
30
|
+
readonly logs: readonly ObsLokiLogSummaryRow[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type ObsErrorsConfigLoader = (options: LoadSessionConfigOptions) => Promise<ObservMeConfig>;
|
|
34
|
+
export type ObsErrorsProvider = (ctx: ObsErrorsCommandContext) => Promise<ObsErrorsSnapshot> | ObsErrorsSnapshot;
|
|
35
|
+
|
|
36
|
+
export interface ObsErrorsSnapshotOptions extends ObsLokiTimeRangeOptions {
|
|
37
|
+
readonly loadConfig?: ObsErrorsConfigLoader;
|
|
38
|
+
readonly fetch?: LokiFetch;
|
|
39
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
40
|
+
readonly configDirName?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RegisterObsErrorsCommandOptions extends ObsErrorsSnapshotOptions {
|
|
44
|
+
readonly getErrors?: ObsErrorsProvider;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const OBS_ERROR_EVENT_NAME_PATTERN = ".*[.]failed|.*[.]dropped|agent[.]orphaned";
|
|
48
|
+
export const OBS_ERRORS_LOGQL = `{service_name="observme-pi-extension", event_name=~"${OBS_ERROR_EVENT_NAME_PATTERN}"}`;
|
|
49
|
+
|
|
50
|
+
const OBS_COMMAND_NAME = "obs";
|
|
51
|
+
const OBS_ERRORS_SUBCOMMAND = "errors";
|
|
52
|
+
const OBS_ERRORS_USAGE = "Usage: /obs errors";
|
|
53
|
+
const OBS_ERRORS_ERROR_NEXT_ACTION = "run /obs health and verify query.grafana.url, Grafana credentials, the Loki datasource UID, and service labels.";
|
|
54
|
+
const OBS_ERRORS_NO_LOGS_NEXT_ACTION = "generate error telemetry, then verify Loki labels and datasource with /obs health.";
|
|
55
|
+
|
|
56
|
+
export function registerObsErrorsCommand(pi: ExtensionAPI, options: RegisterObsErrorsCommandOptions = {}): void {
|
|
57
|
+
const command = new ObsErrorsCommand(options);
|
|
58
|
+
|
|
59
|
+
pi.registerCommand(OBS_COMMAND_NAME, {
|
|
60
|
+
description: "Show recent ObservMe error events from Loki. Usage: /obs errors",
|
|
61
|
+
getArgumentCompletions: getObsErrorsCommandArgumentCompletions,
|
|
62
|
+
handler: command.handle.bind(command),
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function handleObsErrorsCommand(
|
|
67
|
+
args: string,
|
|
68
|
+
ctx: ObsErrorsCommandContext,
|
|
69
|
+
options: RegisterObsErrorsCommandOptions = {},
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
if (!isObsErrorsRequest(args)) {
|
|
72
|
+
await notifyObsCommand(ctx, OBS_ERRORS_USAGE, "warning");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
try {
|
|
77
|
+
const snapshot = await resolveObsErrorsSnapshot(ctx, options);
|
|
78
|
+
await notifyObsCommand(ctx, renderObsErrors(snapshot), "info");
|
|
79
|
+
} catch (error) {
|
|
80
|
+
await notifyObsCommand(
|
|
81
|
+
ctx,
|
|
82
|
+
formatObsCommandFailure("ObservMe errors unavailable", error, {
|
|
83
|
+
subsystem: "Loki",
|
|
84
|
+
nextAction: OBS_ERRORS_ERROR_NEXT_ACTION,
|
|
85
|
+
}),
|
|
86
|
+
"error",
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function getObsErrorsCommandArgumentCompletions(prefix: string): Array<{ value: string; label: string }> | null {
|
|
92
|
+
return completeObsSubcommand(prefix, OBS_ERRORS_SUBCOMMAND);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export async function getObsErrorsSnapshot(
|
|
96
|
+
ctx: ObsErrorsCommandContext,
|
|
97
|
+
options: ObsErrorsSnapshotOptions = {},
|
|
98
|
+
): Promise<ObsErrorsSnapshot> {
|
|
99
|
+
const config = await loadObsErrorsConfig(ctx, options);
|
|
100
|
+
const maxLogs = normalizeObsLokiMaxLogs(config.query.maxLogs);
|
|
101
|
+
const logs = await queryObsErrors(config, options);
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
window: formatObsLokiWindow(options),
|
|
105
|
+
query: OBS_ERRORS_LOGQL,
|
|
106
|
+
maxLogs,
|
|
107
|
+
logs: logs.slice(0, maxLogs).map(toObsLokiLogSummaryRow),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderObsErrors(snapshot: ObsErrorsSnapshot): string {
|
|
112
|
+
return renderObsLokiLogSummary({
|
|
113
|
+
title: "Recent error events",
|
|
114
|
+
window: snapshot.window,
|
|
115
|
+
maxLogs: snapshot.maxLogs,
|
|
116
|
+
rows: snapshot.logs,
|
|
117
|
+
emptyMessage: appendObsRecoveryHint("No error logs found.", OBS_ERRORS_NO_LOGS_NEXT_ACTION),
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
class ObsErrorsCommand {
|
|
122
|
+
readonly #options: RegisterObsErrorsCommandOptions;
|
|
123
|
+
|
|
124
|
+
constructor(options: RegisterObsErrorsCommandOptions) {
|
|
125
|
+
this.#options = options;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async handle(args: string, ctx: ObsErrorsCommandContext): Promise<void> {
|
|
129
|
+
await handleObsErrorsCommand(args, ctx, this.#options);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function resolveObsErrorsSnapshot(
|
|
134
|
+
ctx: ObsErrorsCommandContext,
|
|
135
|
+
options: RegisterObsErrorsCommandOptions,
|
|
136
|
+
): Promise<ObsErrorsSnapshot> {
|
|
137
|
+
if (options.getErrors) return options.getErrors(ctx);
|
|
138
|
+
return getObsErrorsSnapshot(ctx, options);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async function loadObsErrorsConfig(
|
|
142
|
+
ctx: ObsErrorsCommandContext,
|
|
143
|
+
options: ObsErrorsSnapshotOptions,
|
|
144
|
+
): Promise<ObservMeConfig> {
|
|
145
|
+
return loadObsCommandConfig(ctx, options);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function queryObsErrors(config: ObservMeConfig, options: ObsErrorsSnapshotOptions) {
|
|
149
|
+
const client = createLokiQueryClient(config, { fetch: options.fetch });
|
|
150
|
+
return client.queryLoki(OBS_ERRORS_LOGQL, createRecentObsLokiTimeRange(options));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isObsErrorsRequest(args: string): boolean {
|
|
154
|
+
return isExactObsSubcommandRequest(args, OBS_ERRORS_SUBCOMMAND);
|
|
155
|
+
}
|
|
156
|
+
|