@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,481 @@
|
|
|
1
|
+
import type { ClientRequest, IncomingHttpHeaders, IncomingMessage, RequestOptions as HttpRequestOptions } from "node:http";
|
|
2
|
+
import { request as requestHttp } from "node:http";
|
|
3
|
+
import type { RequestOptions as HttpsRequestOptions } from "node:https";
|
|
4
|
+
import { request as requestHttps } from "node:https";
|
|
5
|
+
import type { ObservMeConfig } from "../config/schema.ts";
|
|
6
|
+
import { readDiagnosticMessage, sanitizeDiagnosticText } from "../diagnostics/sanitize.ts";
|
|
7
|
+
import { hasUnresolvedEnvironmentPlaceholder } from "../safety/sensitive-input.ts";
|
|
8
|
+
|
|
9
|
+
export type GrafanaFetch = (input: string | URL, init?: RequestInit) => Promise<Response>;
|
|
10
|
+
export type GrafanaAuthMode = "bearer" | "basic" | "none";
|
|
11
|
+
|
|
12
|
+
export interface GrafanaTransportOptions {
|
|
13
|
+
readonly fetch?: GrafanaFetch;
|
|
14
|
+
readonly timeoutMs?: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface GrafanaTransportFetchOptions {
|
|
18
|
+
readonly method?: string;
|
|
19
|
+
readonly headers?: Record<string, string>;
|
|
20
|
+
readonly timeoutMessage?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface GrafanaAuthReadiness {
|
|
24
|
+
readonly mode: GrafanaAuthMode;
|
|
25
|
+
readonly detail?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface ErrorWithCode {
|
|
29
|
+
readonly code?: unknown;
|
|
30
|
+
readonly cause?: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface GrafanaResponseBodyLimitError extends Error {
|
|
34
|
+
code: "GRAFANA_RESPONSE_BODY_TOO_LARGE";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const MAX_GRAFANA_RESPONSE_BODY_BYTES = 5 * 1024 * 1024;
|
|
38
|
+
export { hasUnresolvedEnvironmentPlaceholder };
|
|
39
|
+
|
|
40
|
+
const minimumTimeoutMs = 1;
|
|
41
|
+
const tlsErrorCodes = new Set<string>([
|
|
42
|
+
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
43
|
+
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
44
|
+
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
45
|
+
"ERR_TLS_CERT_ALTNAME_INVALID",
|
|
46
|
+
"CERT_HAS_EXPIRED",
|
|
47
|
+
]);
|
|
48
|
+
const dnsErrorCodes = new Set<string>(["ENOTFOUND", "EAI_AGAIN"]);
|
|
49
|
+
const connectionTimeoutErrorCodes = new Set<string>(["ETIMEDOUT", "UND_ERR_CONNECT_TIMEOUT"]);
|
|
50
|
+
|
|
51
|
+
export class GrafanaTransportClient {
|
|
52
|
+
readonly #config: ObservMeConfig;
|
|
53
|
+
readonly #fetcher: GrafanaFetch;
|
|
54
|
+
readonly #timeoutMs: number;
|
|
55
|
+
|
|
56
|
+
constructor(config: ObservMeConfig, options: GrafanaTransportOptions = {}) {
|
|
57
|
+
this.#config = config;
|
|
58
|
+
this.#fetcher = resolveGrafanaFetch(config, options.fetch);
|
|
59
|
+
this.#timeoutMs = resolveGrafanaTimeoutMs(config, options.timeoutMs);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get timeoutMs(): number {
|
|
63
|
+
return this.#timeoutMs;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
apiUrl(apiPath: string): URL {
|
|
67
|
+
return buildGrafanaApiUrl(this.#config.query.grafana.url, apiPath);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
datasourceApiUrl(datasourceUid: string, apiPath: string): URL {
|
|
71
|
+
return buildGrafanaDatasourceApiUrl(this.#config.query.grafana.url, datasourceUid, apiPath);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
datasourceProxyUrl(datasourceUid: string, proxyPath: string): URL {
|
|
75
|
+
return buildGrafanaDatasourceProxyUrl(this.#config.query.grafana.url, datasourceUid, proxyPath);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async fetch(input: string | URL, options: GrafanaTransportFetchOptions = {}): Promise<Response> {
|
|
79
|
+
return fetchGrafanaRequest(
|
|
80
|
+
this.#fetcher,
|
|
81
|
+
input,
|
|
82
|
+
createGrafanaRequestInit(this.#config, options),
|
|
83
|
+
this.#timeoutMs,
|
|
84
|
+
options.timeoutMessage,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
formatHttpFailure(response: Response): string {
|
|
89
|
+
return formatGrafanaHttpFailure(response, this.#config);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createGrafanaTransport(
|
|
94
|
+
config: ObservMeConfig,
|
|
95
|
+
options: GrafanaTransportOptions = {},
|
|
96
|
+
): GrafanaTransportClient {
|
|
97
|
+
return new GrafanaTransportClient(config, options);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function buildGrafanaApiUrl(baseUrl: string, apiPath: string): URL {
|
|
101
|
+
const url = new URL(baseUrl.trim());
|
|
102
|
+
const basePath = removeTrailingSlashes(url.pathname);
|
|
103
|
+
const path = removeLeadingSlashes(apiPath);
|
|
104
|
+
url.pathname = `${basePath}/${path}`;
|
|
105
|
+
url.search = "";
|
|
106
|
+
url.hash = "";
|
|
107
|
+
return url;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function removeTrailingSlashes(value: string): string {
|
|
111
|
+
let end = value.length;
|
|
112
|
+
while (end > 0 && value[end - 1] === "/") end -= 1;
|
|
113
|
+
return value.slice(0, end);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function removeLeadingSlashes(value: string): string {
|
|
117
|
+
let start = 0;
|
|
118
|
+
while (start < value.length && value[start] === "/") start += 1;
|
|
119
|
+
return value.slice(start);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function buildGrafanaDatasourceApiUrl(baseUrl: string, datasourceUid: string, apiPath: string): URL {
|
|
123
|
+
return buildGrafanaApiUrl(
|
|
124
|
+
baseUrl,
|
|
125
|
+
joinGrafanaApiPath(`/api/datasources/uid/${encodeURIComponent(datasourceUid)}`, apiPath),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function buildGrafanaDatasourceProxyUrl(baseUrl: string, datasourceUid: string, proxyPath: string): URL {
|
|
130
|
+
return buildGrafanaApiUrl(
|
|
131
|
+
baseUrl,
|
|
132
|
+
joinGrafanaApiPath(`/api/datasources/proxy/uid/${encodeURIComponent(datasourceUid)}`, proxyPath),
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function resolveGrafanaTimeoutMs(config: ObservMeConfig, overrideMs?: number): number {
|
|
137
|
+
const timeoutMs = overrideMs ?? config.query.timeoutMs;
|
|
138
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs < minimumTimeoutMs) return minimumTimeoutMs;
|
|
139
|
+
return Math.trunc(timeoutMs);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function createGrafanaHeaders(config: ObservMeConfig): Record<string, string> {
|
|
143
|
+
const headers: Record<string, string> = { Accept: "application/json" };
|
|
144
|
+
const authorization = createGrafanaAuthorizationHeader(config);
|
|
145
|
+
|
|
146
|
+
if (authorization) headers.Authorization = authorization;
|
|
147
|
+
return headers;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function resolveGrafanaFetch(config: ObservMeConfig, fetcher?: GrafanaFetch): GrafanaFetch {
|
|
151
|
+
if (fetcher) return fetcher;
|
|
152
|
+
if (requiresCustomGrafanaTransport(config)) return (input, init) => fetchGrafanaWithNode(config, input, init);
|
|
153
|
+
return defaultGrafanaFetch;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function requiresCustomGrafanaTransport(config: ObservMeConfig): boolean {
|
|
157
|
+
return Boolean(config.query.grafana.transport.preferIPv4 || config.query.grafana.tls.insecureSkipVerify);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function formatGrafanaHttpFailure(response: Response, config: ObservMeConfig): string {
|
|
161
|
+
const status = formatHttpStatus(response);
|
|
162
|
+
if (!isGrafanaAuthFailure(response.status)) return status;
|
|
163
|
+
|
|
164
|
+
const readiness = getGrafanaAuthReadiness(config);
|
|
165
|
+
if (readiness.mode === "none") return `${status}; ${readiness.detail ?? "Grafana authentication is not configured."}`;
|
|
166
|
+
return `${status}; Grafana authentication failed. Check query.grafana credentials without exposing token or password values.`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function formatGrafanaFetchFailure(error: unknown): string {
|
|
170
|
+
if (isAbortError(error)) return "timed out";
|
|
171
|
+
|
|
172
|
+
const code = readErrorCode(error);
|
|
173
|
+
if (code && tlsErrorCodes.has(code)) {
|
|
174
|
+
return "TLS certificate verification failed for Grafana. Trust the local certificate or set query.grafana.tls.insecureSkipVerify=true for local development only.";
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (code && dnsErrorCodes.has(code)) {
|
|
178
|
+
return "DNS lookup failed for Grafana. Configure query.grafana.url with a resolvable host or enable query.grafana.transport.preferIPv4 for local development.";
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (code === "ECONNREFUSED") {
|
|
182
|
+
return "Grafana connection refused. Verify the local observability stack is running and query.grafana.url points at it.";
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (code && connectionTimeoutErrorCodes.has(code)) return "Grafana connection timed out.";
|
|
186
|
+
if (code === "GRAFANA_RESPONSE_BODY_TOO_LARGE") return formatError(error);
|
|
187
|
+
return formatError(error);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function normalizeConfiguredGrafanaSecret(value: string): string | undefined {
|
|
191
|
+
const trimmed = value.trim();
|
|
192
|
+
if (!trimmed || hasUnresolvedEnvironmentPlaceholder(trimmed)) return undefined;
|
|
193
|
+
return trimmed;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function fetchGrafanaRequest(
|
|
197
|
+
fetcher: GrafanaFetch,
|
|
198
|
+
input: string | URL,
|
|
199
|
+
init: RequestInit,
|
|
200
|
+
timeoutMs: number,
|
|
201
|
+
timeoutMessage: string | undefined,
|
|
202
|
+
): Promise<Response> {
|
|
203
|
+
const controller = new AbortController();
|
|
204
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
205
|
+
|
|
206
|
+
try {
|
|
207
|
+
return await fetcher(input, { ...init, signal: controller.signal });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
throw normalizeGrafanaTransportError(error, timeoutMessage);
|
|
210
|
+
} finally {
|
|
211
|
+
clearTimeout(timeout);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function createGrafanaRequestInit(config: ObservMeConfig, options: GrafanaTransportFetchOptions): RequestInit {
|
|
216
|
+
return {
|
|
217
|
+
method: options.method ?? "GET",
|
|
218
|
+
headers: { ...createGrafanaHeaders(config), ...options.headers },
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function normalizeGrafanaTransportError(error: unknown, timeoutMessage: string | undefined): Error {
|
|
223
|
+
if (timeoutMessage && isAbortError(error)) return new Error(timeoutMessage);
|
|
224
|
+
return new Error(formatGrafanaFetchFailure(error));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function joinGrafanaApiPath(prefix: string, path: string): string {
|
|
228
|
+
const normalizedPrefix = removeTrailingSlashes(prefix);
|
|
229
|
+
const normalizedPath = removeLeadingSlashes(path);
|
|
230
|
+
if (!normalizedPath) return normalizedPrefix;
|
|
231
|
+
return `${normalizedPrefix}/${normalizedPath}`;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function fetchGrafanaWithNode(
|
|
235
|
+
config: ObservMeConfig,
|
|
236
|
+
input: string | URL,
|
|
237
|
+
init?: RequestInit,
|
|
238
|
+
): Promise<Response> {
|
|
239
|
+
const url = new URL(input);
|
|
240
|
+
assertSupportedGrafanaUrl(url);
|
|
241
|
+
|
|
242
|
+
const requestOptions = createNodeRequestOptions(config, init);
|
|
243
|
+
const body = normalizeRequestBody(init?.body);
|
|
244
|
+
return requestNodeUrl(url, requestOptions, body, init?.signal);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function createGrafanaAuthorizationHeader(config: ObservMeConfig): string | undefined {
|
|
248
|
+
const token = normalizeConfiguredGrafanaSecret(config.query.grafana.token);
|
|
249
|
+
if (token) return `Bearer ${token}`;
|
|
250
|
+
|
|
251
|
+
const username = normalizeConfiguredGrafanaSecret(config.query.grafana.username);
|
|
252
|
+
const password = normalizeConfiguredGrafanaSecret(config.query.grafana.password);
|
|
253
|
+
if (username && password) {
|
|
254
|
+
const credentials = `${username}:${password}`;
|
|
255
|
+
const encodedCredentials = Buffer.from(credentials).toString("base64");
|
|
256
|
+
return `Basic ${encodedCredentials}`;
|
|
257
|
+
}
|
|
258
|
+
return undefined;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function getGrafanaAuthReadiness(config: ObservMeConfig): GrafanaAuthReadiness {
|
|
262
|
+
if (normalizeConfiguredGrafanaSecret(config.query.grafana.token)) return { mode: "bearer" };
|
|
263
|
+
|
|
264
|
+
const username = normalizeConfiguredGrafanaSecret(config.query.grafana.username);
|
|
265
|
+
const password = normalizeConfiguredGrafanaSecret(config.query.grafana.password);
|
|
266
|
+
if (username && password) return { mode: "basic" };
|
|
267
|
+
|
|
268
|
+
return { mode: "none", detail: describeMissingGrafanaAuth(config) };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function describeMissingGrafanaAuth(config: ObservMeConfig): string {
|
|
272
|
+
const token = config.query.grafana.token.trim();
|
|
273
|
+
const username = config.query.grafana.username.trim();
|
|
274
|
+
const password = config.query.grafana.password.trim();
|
|
275
|
+
|
|
276
|
+
if (hasUnresolvedEnvironmentPlaceholder(token)) {
|
|
277
|
+
return "Grafana authentication is not configured because query.grafana.token is unresolved. Set the referenced environment variable or configure query.grafana.username/password.";
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (hasUnresolvedEnvironmentPlaceholder(username) || hasUnresolvedEnvironmentPlaceholder(password)) {
|
|
281
|
+
return "Grafana authentication is not configured because query.grafana.username/password contains an unresolved environment placeholder.";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (username || password) {
|
|
285
|
+
return "Grafana authentication is incomplete. Configure both query.grafana.username and query.grafana.password, or configure query.grafana.token.";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return "Grafana authentication is not configured. Configure query.grafana.token or query.grafana.username/password.";
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function createNodeRequestOptions(config: ObservMeConfig, init: RequestInit | undefined): HttpRequestOptions | HttpsRequestOptions {
|
|
292
|
+
const options: HttpRequestOptions | HttpsRequestOptions = {
|
|
293
|
+
method: init?.method ?? "GET",
|
|
294
|
+
headers: normalizeRequestHeaders(init?.headers),
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
if (config.query.grafana.transport.preferIPv4) options.family = 4;
|
|
298
|
+
if (config.query.grafana.tls.insecureSkipVerify) {
|
|
299
|
+
(options as HttpsRequestOptions).rejectUnauthorized = false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return options;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function normalizeRequestHeaders(headers: HeadersInit | undefined): Record<string, string> {
|
|
306
|
+
const normalized: Record<string, string> = {};
|
|
307
|
+
if (!headers) return normalized;
|
|
308
|
+
|
|
309
|
+
if (headers instanceof Headers) {
|
|
310
|
+
headers.forEach((value, key) => {
|
|
311
|
+
normalized[key] = value;
|
|
312
|
+
});
|
|
313
|
+
return normalized;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
if (Array.isArray(headers)) {
|
|
317
|
+
for (const [key, value] of headers) normalized[key] = value;
|
|
318
|
+
return normalized;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
for (const [key, value] of Object.entries(headers)) normalized[key] = String(value);
|
|
322
|
+
return normalized;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function normalizeRequestBody(body: BodyInit | null | undefined): string | Buffer | undefined {
|
|
326
|
+
if (body === undefined || body === null) return undefined;
|
|
327
|
+
if (typeof body === "string") return body;
|
|
328
|
+
if (body instanceof URLSearchParams) return body.toString();
|
|
329
|
+
if (body instanceof Uint8Array) return Buffer.from(body);
|
|
330
|
+
if (body instanceof ArrayBuffer) return Buffer.from(body);
|
|
331
|
+
throw new Error("Grafana custom transport supports string or binary request bodies only.");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function assertSupportedGrafanaUrl(url: URL): void {
|
|
335
|
+
if (url.protocol === "http:" || url.protocol === "https:") return;
|
|
336
|
+
throw new Error(`Grafana custom transport supports http:// and https:// URLs only: ${url.protocol}`);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function requestNodeUrl(
|
|
340
|
+
url: URL,
|
|
341
|
+
options: HttpRequestOptions | HttpsRequestOptions,
|
|
342
|
+
body: string | Buffer | undefined,
|
|
343
|
+
signal: AbortSignal | null | undefined,
|
|
344
|
+
): Promise<Response> {
|
|
345
|
+
return new Promise((resolve, reject) => {
|
|
346
|
+
const client = url.protocol === "https:" ? requestHttps : requestHttp;
|
|
347
|
+
const request = client(url, options, response => {
|
|
348
|
+
readNodeResponse(response).then(resolve, reject);
|
|
349
|
+
});
|
|
350
|
+
const removeAbortListener = attachAbortSignal(request, signal);
|
|
351
|
+
|
|
352
|
+
request.on("error", error => {
|
|
353
|
+
removeAbortListener();
|
|
354
|
+
reject(error);
|
|
355
|
+
});
|
|
356
|
+
request.on("close", removeAbortListener);
|
|
357
|
+
if (body) request.write(body);
|
|
358
|
+
request.end();
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function attachAbortSignal(request: ClientRequest, signal: AbortSignal | null | undefined): () => void {
|
|
363
|
+
if (!signal) return noop;
|
|
364
|
+
|
|
365
|
+
const abortRequest = () => request.destroy(createAbortError());
|
|
366
|
+
if (signal.aborted) {
|
|
367
|
+
abortRequest();
|
|
368
|
+
return noop;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
signal.addEventListener("abort", abortRequest, { once: true });
|
|
372
|
+
return () => signal.removeEventListener("abort", abortRequest);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async function readNodeResponse(response: IncomingMessage): Promise<Response> {
|
|
376
|
+
const body = await readNodeResponseBody(response, MAX_GRAFANA_RESPONSE_BODY_BYTES);
|
|
377
|
+
const status = normalizeResponseStatus(response.statusCode);
|
|
378
|
+
const statusText = response.statusMessage ?? "";
|
|
379
|
+
return new Response(body.toString("utf8"), { status, statusText, headers: normalizeResponseHeaders(response.headers) });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
async function readNodeResponseBody(response: IncomingMessage, maxBytes: number): Promise<Buffer> {
|
|
383
|
+
const chunks: Buffer[] = [];
|
|
384
|
+
let totalBytes = 0;
|
|
385
|
+
|
|
386
|
+
for await (const chunk of response) {
|
|
387
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
388
|
+
totalBytes += buffer.byteLength;
|
|
389
|
+
if (totalBytes > maxBytes) throw destroyOversizedNodeResponse(response, maxBytes);
|
|
390
|
+
chunks.push(buffer);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return Buffer.concat(chunks, totalBytes);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function destroyOversizedNodeResponse(response: IncomingMessage, maxBytes: number): GrafanaResponseBodyLimitError {
|
|
397
|
+
const error = createGrafanaResponseBodyLimitError(maxBytes);
|
|
398
|
+
response.destroy(error);
|
|
399
|
+
return error;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function createGrafanaResponseBodyLimitError(maxBytes: number): GrafanaResponseBodyLimitError {
|
|
403
|
+
const error = new Error(
|
|
404
|
+
`Grafana response body exceeded maximum size of ${maxBytes} bytes. Narrow the query or lower query result limits.`,
|
|
405
|
+
) as GrafanaResponseBodyLimitError;
|
|
406
|
+
error.code = "GRAFANA_RESPONSE_BODY_TOO_LARGE";
|
|
407
|
+
return error;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function normalizeResponseHeaders(headers: IncomingHttpHeaders): Headers {
|
|
411
|
+
const normalized = new Headers();
|
|
412
|
+
|
|
413
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
414
|
+
if (Array.isArray(value)) {
|
|
415
|
+
for (const item of value) normalized.append(name, item);
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (value !== undefined) normalized.append(name, String(value));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return normalized;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function normalizeResponseStatus(status: number | undefined): number {
|
|
426
|
+
if (status !== undefined && status >= 200 && status <= 599) return status;
|
|
427
|
+
return 500;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function defaultGrafanaFetch(input: string | URL, init?: RequestInit): Promise<Response> {
|
|
431
|
+
return globalThis.fetch(input, init);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function isGrafanaAuthFailure(status: number): boolean {
|
|
435
|
+
return status === 401 || status === 403;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function formatHttpStatus(response: Response): string {
|
|
439
|
+
const statusText = response.statusText.trim();
|
|
440
|
+
return statusText ? `HTTP ${response.status} ${statusText}` : `HTTP ${response.status}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
function readErrorCode(error: unknown): string | undefined {
|
|
444
|
+
const code = readOwnErrorCode(error);
|
|
445
|
+
if (code) return code;
|
|
446
|
+
|
|
447
|
+
const cause = readErrorCause(error);
|
|
448
|
+
return cause ? readErrorCode(cause) : undefined;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function readOwnErrorCode(error: unknown): string | undefined {
|
|
452
|
+
if (!isErrorWithCode(error) || typeof error.code !== "string") return undefined;
|
|
453
|
+
return error.code;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function readErrorCause(error: unknown): unknown {
|
|
457
|
+
if (!isErrorWithCode(error)) return undefined;
|
|
458
|
+
return error.cause;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function isErrorWithCode(error: unknown): error is ErrorWithCode {
|
|
462
|
+
return typeof error === "object" && error !== null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function createAbortError(): DOMException {
|
|
466
|
+
return new DOMException("The operation was aborted.", "AbortError");
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
function isAbortError(error: unknown): boolean {
|
|
470
|
+
return isNamedError(error) && error.name === "AbortError";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function isNamedError(error: unknown): error is { name: string } {
|
|
474
|
+
return typeof error === "object" && error !== null && "name" in error && typeof error.name === "string";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function formatError(error: unknown): string {
|
|
478
|
+
return sanitizeDiagnosticText(readDiagnosticMessage(error));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function noop(): void {}
|