@blogic-cz/agent-tools 0.11.0 → 0.12.1
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/README.md +60 -60
- package/package.json +8 -8
- package/schemas/agent-tools.schema.json +12 -12
- package/src/config/index.ts +2 -2
- package/src/config/loader.ts +6 -6
- package/src/config/types.ts +7 -7
- package/src/gh-tool/release.ts +10 -2
- package/src/index.ts +1 -1
- package/src/observability-tool/errors.ts +8 -0
- package/src/{grafana-tool → observability-tool}/index.ts +7 -16
- package/src/observability-tool/metrics.ts +129 -0
- package/src/{grafana-tool → observability-tool}/shared.ts +90 -34
- package/src/observability-tool/trace.ts +379 -0
- package/src/observability-tool/types.ts +119 -0
- package/src/grafana-tool/alerts.ts +0 -159
- package/src/grafana-tool/dashboards.ts +0 -151
- package/src/grafana-tool/datasources.ts +0 -72
- package/src/grafana-tool/errors.ts +0 -8
- package/src/grafana-tool/health.ts +0 -57
- package/src/grafana-tool/logs.ts +0 -124
- package/src/grafana-tool/metrics.ts +0 -217
- package/src/grafana-tool/types.ts +0 -29
|
@@ -2,17 +2,24 @@ import { Effect, Option } from "effect";
|
|
|
2
2
|
import { Flag } from "effect/unstable/cli";
|
|
3
3
|
|
|
4
4
|
import { ConfigService, getToolConfig } from "#config";
|
|
5
|
-
import type {
|
|
5
|
+
import type { ObservabilityConfig } from "#config";
|
|
6
6
|
|
|
7
|
-
import {
|
|
8
|
-
import type {
|
|
7
|
+
import { ObservabilityToolError } from "./errors";
|
|
8
|
+
import type {
|
|
9
|
+
DsQueryOpts,
|
|
10
|
+
DsQueryResponse,
|
|
11
|
+
GrafanaDatasource,
|
|
12
|
+
ObservabilityEnvConfig,
|
|
13
|
+
} from "./types";
|
|
9
14
|
|
|
10
15
|
const DEFAULT_LOCAL_URL = "http://localhost:40300";
|
|
11
16
|
const DEFAULT_PROMETHEUS_UID = "prometheus";
|
|
12
17
|
const DEFAULT_LOKI_UID = "loki";
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
const DEFAULT_TEMPO_UID = "tempo";
|
|
19
|
+
|
|
20
|
+
export function formatObservabilityError(error: unknown): string {
|
|
21
|
+
if (error instanceof ObservabilityToolError) {
|
|
22
|
+
return formatObservabilityError(error.cause);
|
|
16
23
|
}
|
|
17
24
|
|
|
18
25
|
if (error instanceof Error) {
|
|
@@ -30,7 +37,7 @@ export const envOption = Flag.string("env").pipe(
|
|
|
30
37
|
export const profileOption = Flag.optional(
|
|
31
38
|
Flag.string("profile").pipe(
|
|
32
39
|
Flag.withDescription(
|
|
33
|
-
"
|
|
40
|
+
"Observability profile name from agent-tools config (default: 'default' key or single entry)",
|
|
34
41
|
),
|
|
35
42
|
),
|
|
36
43
|
);
|
|
@@ -48,43 +55,67 @@ function resolveToken(tokenEnvVar?: string): string | undefined {
|
|
|
48
55
|
return token;
|
|
49
56
|
}
|
|
50
57
|
|
|
51
|
-
function
|
|
52
|
-
|
|
58
|
+
async function discoverDatasources(url: string, token?: string): Promise<GrafanaDatasource[]> {
|
|
59
|
+
const headers = buildHeaders(token);
|
|
60
|
+
const response = await fetch(`${url}/api/datasources`, { headers });
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const body = await response.text().catch(() => "");
|
|
64
|
+
throw new Error(`Grafana API ${response.status}: /api/datasources — ${body}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return (await response.json()) as GrafanaDatasource[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveFromProfile(
|
|
71
|
+
profile: ObservabilityConfig | undefined,
|
|
53
72
|
env: string,
|
|
54
|
-
):
|
|
73
|
+
): Promise<ObservabilityEnvConfig | undefined> {
|
|
55
74
|
const environment = profile?.environments[env];
|
|
56
75
|
if (!environment) {
|
|
57
76
|
return undefined;
|
|
58
77
|
}
|
|
59
78
|
|
|
79
|
+
const token = resolveToken(environment.tokenEnvVar);
|
|
80
|
+
const datasources = await discoverDatasources(environment.url, token);
|
|
81
|
+
|
|
82
|
+
const tempoUid =
|
|
83
|
+
datasources.find((datasource) => datasource.uid === DEFAULT_TEMPO_UID)?.uid ??
|
|
84
|
+
datasources.find((datasource) => datasource.type === "tempo")?.uid;
|
|
85
|
+
|
|
86
|
+
if (!tempoUid) {
|
|
87
|
+
throw new Error(`No Tempo datasource found in observability.${env} config`);
|
|
88
|
+
}
|
|
89
|
+
|
|
60
90
|
return {
|
|
61
91
|
url: environment.url,
|
|
62
|
-
token
|
|
92
|
+
token,
|
|
63
93
|
prometheusUid: environment.prometheusUid ?? DEFAULT_PROMETHEUS_UID,
|
|
64
94
|
lokiUid: environment.lokiUid ?? DEFAULT_LOKI_UID,
|
|
95
|
+
tempoUid,
|
|
65
96
|
};
|
|
66
97
|
}
|
|
67
98
|
|
|
68
|
-
function resolveFromEnv(
|
|
99
|
+
function resolveFromEnv(
|
|
100
|
+
env: string,
|
|
101
|
+
): Pick<ObservabilityEnvConfig, "url" | "token" | "prometheusUid" | "lokiUid"> {
|
|
69
102
|
if (env === "local") {
|
|
70
103
|
return {
|
|
71
|
-
url: process.env.
|
|
72
|
-
token: process.env.
|
|
104
|
+
url: process.env.OBSERVABILITY_URL_LOCAL ?? DEFAULT_LOCAL_URL,
|
|
105
|
+
token: process.env.OBSERVABILITY_TOKEN_LOCAL,
|
|
73
106
|
prometheusUid: DEFAULT_PROMETHEUS_UID,
|
|
74
107
|
lokiUid: DEFAULT_LOKI_UID,
|
|
75
108
|
};
|
|
76
109
|
}
|
|
77
110
|
|
|
78
111
|
const upper = env.toUpperCase();
|
|
79
|
-
const url = process.env[`
|
|
80
|
-
const token = process.env[`
|
|
112
|
+
const url = process.env[`OBSERVABILITY_URL_${upper}`];
|
|
113
|
+
const token = process.env[`OBSERVABILITY_TOKEN_${upper}`];
|
|
81
114
|
|
|
82
115
|
if (!url) {
|
|
83
|
-
throw new Error(
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!token) {
|
|
87
|
-
throw new Error(`No grafana.${env} config found and GRAFANA_TOKEN_${upper} is not set`);
|
|
116
|
+
throw new Error(
|
|
117
|
+
`No observability.${env} config found and OBSERVABILITY_URL_${upper} is not set`,
|
|
118
|
+
);
|
|
88
119
|
}
|
|
89
120
|
|
|
90
121
|
return {
|
|
@@ -99,11 +130,36 @@ export const resolveConfig = (env: string, profile: Option.Option<string>) =>
|
|
|
99
130
|
Effect.gen(function* () {
|
|
100
131
|
const config = yield* ConfigService;
|
|
101
132
|
const profileName = Option.getOrUndefined(profile);
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
133
|
+
const observabilityConfig = getToolConfig<ObservabilityConfig>(
|
|
134
|
+
config,
|
|
135
|
+
"observability",
|
|
136
|
+
profileName,
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
return yield* Effect.tryPromise({
|
|
140
|
+
try: async () => {
|
|
141
|
+
const profileResolved = await resolveFromProfile(observabilityConfig, env);
|
|
142
|
+
if (profileResolved) {
|
|
143
|
+
return profileResolved;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const resolved = resolveFromEnv(env);
|
|
147
|
+
|
|
148
|
+
const datasources = await discoverDatasources(resolved.url, resolved.token);
|
|
149
|
+
const tempoUid =
|
|
150
|
+
datasources.find((datasource) => datasource.uid === DEFAULT_TEMPO_UID)?.uid ??
|
|
151
|
+
datasources.find((datasource) => datasource.type === "tempo")?.uid;
|
|
152
|
+
|
|
153
|
+
if (!tempoUid) {
|
|
154
|
+
throw new Error(`No Tempo datasource found for environment '${env}'`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
...resolved,
|
|
159
|
+
tempoUid,
|
|
160
|
+
} satisfies ObservabilityEnvConfig;
|
|
161
|
+
},
|
|
162
|
+
catch: (cause) => new ObservabilityToolError({ cause }),
|
|
107
163
|
});
|
|
108
164
|
});
|
|
109
165
|
|
|
@@ -120,11 +176,11 @@ export function buildHeaders(token?: string): Headers {
|
|
|
120
176
|
return headers;
|
|
121
177
|
}
|
|
122
178
|
|
|
123
|
-
export function
|
|
124
|
-
config:
|
|
179
|
+
export function observabilityFetch<T>(
|
|
180
|
+
config: ObservabilityEnvConfig,
|
|
125
181
|
path: string,
|
|
126
182
|
init?: RequestInit,
|
|
127
|
-
): Effect.Effect<T,
|
|
183
|
+
): Effect.Effect<T, ObservabilityToolError> {
|
|
128
184
|
return Effect.tryPromise({
|
|
129
185
|
try: async () => {
|
|
130
186
|
const headers = buildHeaders(config.token);
|
|
@@ -147,17 +203,17 @@ export function grafanaFetch<T>(
|
|
|
147
203
|
|
|
148
204
|
return (await response.json()) as T;
|
|
149
205
|
},
|
|
150
|
-
catch: (cause) => new
|
|
206
|
+
catch: (cause) => new ObservabilityToolError({ cause }),
|
|
151
207
|
});
|
|
152
208
|
}
|
|
153
209
|
|
|
154
|
-
export function
|
|
155
|
-
config:
|
|
210
|
+
export function observabilityDsQuery(
|
|
211
|
+
config: ObservabilityEnvConfig,
|
|
156
212
|
datasourceUid: string,
|
|
157
213
|
datasourceType: "prometheus" | "loki",
|
|
158
214
|
expr: string,
|
|
159
215
|
options?: DsQueryOpts,
|
|
160
|
-
): Effect.Effect<DsQueryResponse,
|
|
216
|
+
): Effect.Effect<DsQueryResponse, ObservabilityToolError> {
|
|
161
217
|
const opts = options ?? {};
|
|
162
218
|
const query: Record<string, unknown> = {
|
|
163
219
|
refId: "A",
|
|
@@ -183,7 +239,7 @@ export function grafanaDsQuery(
|
|
|
183
239
|
}
|
|
184
240
|
}
|
|
185
241
|
|
|
186
|
-
return
|
|
242
|
+
return observabilityFetch<DsQueryResponse>(config, "/api/ds/query", {
|
|
187
243
|
method: "POST",
|
|
188
244
|
body: JSON.stringify({
|
|
189
245
|
queries: [query],
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
import { Console, Effect } from "effect";
|
|
2
|
+
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
3
|
+
|
|
4
|
+
import { formatOption, formatOutput } from "#shared";
|
|
5
|
+
|
|
6
|
+
import { ObservabilityToolError } from "./errors";
|
|
7
|
+
import {
|
|
8
|
+
envOption,
|
|
9
|
+
formatObservabilityError,
|
|
10
|
+
observabilityDsQuery,
|
|
11
|
+
observabilityFetch,
|
|
12
|
+
profileOption,
|
|
13
|
+
resolveConfig,
|
|
14
|
+
} from "./shared";
|
|
15
|
+
import type {
|
|
16
|
+
FlattenedSpan,
|
|
17
|
+
OtlpAnyValue,
|
|
18
|
+
OtlpAttribute,
|
|
19
|
+
TempoTraceResponse,
|
|
20
|
+
TraceSummary,
|
|
21
|
+
} from "./types";
|
|
22
|
+
|
|
23
|
+
function isHexTraceId(value: string): boolean {
|
|
24
|
+
return /^[\da-f]{32}$/i.test(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getStringAttribute(
|
|
28
|
+
attributes: ReadonlyArray<OtlpAttribute> | null,
|
|
29
|
+
key: string,
|
|
30
|
+
): string | undefined {
|
|
31
|
+
const attribute = attributes?.find((item) => item.key === key);
|
|
32
|
+
const value = attribute?.value;
|
|
33
|
+
|
|
34
|
+
if (value?.stringValue !== undefined) return value.stringValue;
|
|
35
|
+
if (value?.intValue !== undefined) return String(value.intValue);
|
|
36
|
+
if (value?.doubleValue !== undefined) return String(value.doubleValue);
|
|
37
|
+
if (value?.boolValue !== undefined) return String(value.boolValue);
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function anyValueToUnknown(value?: OtlpAnyValue): unknown {
|
|
42
|
+
if (value === undefined) return undefined;
|
|
43
|
+
if (value.stringValue !== undefined) return value.stringValue;
|
|
44
|
+
if (value.boolValue !== undefined) return value.boolValue;
|
|
45
|
+
if (value.intValue !== undefined) return value.intValue;
|
|
46
|
+
if (value.doubleValue !== undefined) return value.doubleValue;
|
|
47
|
+
if (value.arrayValue?.values !== undefined) {
|
|
48
|
+
return value.arrayValue.values.map((item) => anyValueToUnknown(item));
|
|
49
|
+
}
|
|
50
|
+
if (value.kvlistValue?.values !== undefined) {
|
|
51
|
+
return Object.fromEntries(
|
|
52
|
+
value.kvlistValue.values.map((entry) => [entry.key, anyValueToUnknown(entry.value)]),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function attributesToRecord(
|
|
59
|
+
attributes: ReadonlyArray<OtlpAttribute> | null,
|
|
60
|
+
): Record<string, unknown> {
|
|
61
|
+
return Object.fromEntries(
|
|
62
|
+
(attributes ?? [])
|
|
63
|
+
.map((attribute) => [attribute.key, anyValueToUnknown(attribute.value)] as const)
|
|
64
|
+
.filter((entry) => entry[1] !== undefined),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function decodeIdToHex(value?: string, expectedBytes?: number): string | undefined {
|
|
69
|
+
if (value === undefined) return undefined;
|
|
70
|
+
const trimmed = value.trim();
|
|
71
|
+
if (trimmed.length === 0) return undefined;
|
|
72
|
+
|
|
73
|
+
const expectedHexLength = expectedBytes === undefined ? undefined : expectedBytes * 2;
|
|
74
|
+
if (
|
|
75
|
+
expectedHexLength !== undefined &&
|
|
76
|
+
new RegExp(`^[\\da-f]{${expectedHexLength}}$`, "i").test(trimmed)
|
|
77
|
+
) {
|
|
78
|
+
return trimmed.toLowerCase();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
const bytes = Buffer.from(trimmed, "base64");
|
|
83
|
+
if (expectedBytes !== undefined && bytes.length !== expectedBytes) {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return bytes.toString("hex");
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function nanoToBigInt(value?: string): bigint | undefined {
|
|
93
|
+
if (value === undefined || value.trim().length === 0) {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
return BigInt(value);
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function computeDurationMs(start?: string, end?: string): number | undefined {
|
|
104
|
+
const startNano = nanoToBigInt(start);
|
|
105
|
+
const endNano = nanoToBigInt(end);
|
|
106
|
+
|
|
107
|
+
if (startNano === undefined || endNano === undefined || endNano < startNano) {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return Number(endNano - startNano) / 1_000_000;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function isErrorStatus(code?: string | number): boolean {
|
|
115
|
+
if (code === undefined) return false;
|
|
116
|
+
if (typeof code === "number") return code === 2;
|
|
117
|
+
return code.toUpperCase().includes("ERROR") || code === "2";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function flattenTrace(trace: TempoTraceResponse): FlattenedSpan[] {
|
|
121
|
+
const spans: FlattenedSpan[] = [];
|
|
122
|
+
|
|
123
|
+
for (const batch of trace.batches ?? []) {
|
|
124
|
+
const resourceAttributes = attributesToRecord(batch.resource?.attributes ?? null);
|
|
125
|
+
const serviceName =
|
|
126
|
+
getStringAttribute(batch.resource?.attributes ?? null, "service.name") ?? "unknown-service";
|
|
127
|
+
|
|
128
|
+
for (const scopeSpan of batch.scopeSpans ?? []) {
|
|
129
|
+
for (const span of scopeSpan.spans ?? []) {
|
|
130
|
+
spans.push({
|
|
131
|
+
serviceName,
|
|
132
|
+
scopeName: scopeSpan.scope?.name,
|
|
133
|
+
name: span.name ?? "unknown-span",
|
|
134
|
+
kind: span.kind,
|
|
135
|
+
traceId: decodeIdToHex(span.traceId, 16),
|
|
136
|
+
spanId: decodeIdToHex(span.spanId, 8),
|
|
137
|
+
parentSpanId: decodeIdToHex(span.parentSpanId, 8),
|
|
138
|
+
startTimeUnixNano: span.startTimeUnixNano,
|
|
139
|
+
endTimeUnixNano: span.endTimeUnixNano,
|
|
140
|
+
durationMs: computeDurationMs(span.startTimeUnixNano, span.endTimeUnixNano),
|
|
141
|
+
statusCode: span.status?.code,
|
|
142
|
+
statusMessage: span.status?.message,
|
|
143
|
+
isError: isErrorStatus(span.status?.code),
|
|
144
|
+
attributes: attributesToRecord(span.attributes ?? null),
|
|
145
|
+
resourceAttributes,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return spans.toSorted((left, right) => {
|
|
152
|
+
const leftStart = nanoToBigInt(left.startTimeUnixNano) ?? 0n;
|
|
153
|
+
const rightStart = nanoToBigInt(right.startTimeUnixNano) ?? 0n;
|
|
154
|
+
return leftStart < rightStart ? -1 : leftStart > rightStart ? 1 : 0;
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function summarizeTrace(traceId: string, spans: readonly FlattenedSpan[]): TraceSummary {
|
|
159
|
+
const services = [...new Set(spans.map((span) => span.serviceName))].toSorted();
|
|
160
|
+
const spanIds = new Set(
|
|
161
|
+
spans.map((span) => span.spanId).filter((value): value is string => value !== undefined),
|
|
162
|
+
);
|
|
163
|
+
const rootSpans = spans.filter(
|
|
164
|
+
(span) => span.parentSpanId === undefined || !spanIds.has(span.parentSpanId),
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const startedAt = spans.reduce<bigint | undefined>((current, span) => {
|
|
168
|
+
const value = nanoToBigInt(span.startTimeUnixNano);
|
|
169
|
+
if (value === undefined) return current;
|
|
170
|
+
if (current === undefined || value < current) return value;
|
|
171
|
+
return current;
|
|
172
|
+
}, undefined);
|
|
173
|
+
const endedAt = spans.reduce<bigint | undefined>((current, span) => {
|
|
174
|
+
const value = nanoToBigInt(span.endTimeUnixNano);
|
|
175
|
+
if (value === undefined) return current;
|
|
176
|
+
if (current === undefined || value > current) return value;
|
|
177
|
+
return current;
|
|
178
|
+
}, undefined);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
traceId,
|
|
182
|
+
spanCount: spans.length,
|
|
183
|
+
serviceCount: services.length,
|
|
184
|
+
services,
|
|
185
|
+
errorSpanCount: spans.filter((span) => span.isError).length,
|
|
186
|
+
rootSpans,
|
|
187
|
+
totalDurationMs:
|
|
188
|
+
startedAt !== undefined && endedAt !== undefined && endedAt >= startedAt
|
|
189
|
+
? Number(endedAt - startedAt) / 1_000_000
|
|
190
|
+
: undefined,
|
|
191
|
+
startedAtUnixNano: startedAt?.toString(),
|
|
192
|
+
endedAtUnixNano: endedAt?.toString(),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseLabel(value: string | Record<string, string>): Record<string, string> {
|
|
197
|
+
if (typeof value === "object" && value !== null) {
|
|
198
|
+
return value;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
return JSON.parse(value) as Record<string, string>;
|
|
203
|
+
} catch {
|
|
204
|
+
return {};
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const getCommand = Command.make(
|
|
209
|
+
"get",
|
|
210
|
+
{
|
|
211
|
+
traceId: Argument.string("traceId"),
|
|
212
|
+
format: formatOption,
|
|
213
|
+
env: envOption,
|
|
214
|
+
profile: profileOption,
|
|
215
|
+
},
|
|
216
|
+
({ traceId, format, env, profile }) => {
|
|
217
|
+
const startedAt = Date.now();
|
|
218
|
+
|
|
219
|
+
return Effect.gen(function* () {
|
|
220
|
+
if (!isHexTraceId(traceId)) {
|
|
221
|
+
return yield* new ObservabilityToolError({
|
|
222
|
+
cause: new Error("trace get requires a 32-character hex trace ID"),
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const config = yield* resolveConfig(env, profile);
|
|
227
|
+
const raw = yield* observabilityFetch<TempoTraceResponse>(
|
|
228
|
+
config,
|
|
229
|
+
`/api/datasources/proxy/uid/${config.tempoUid}/api/traces/${traceId.toLowerCase()}`,
|
|
230
|
+
);
|
|
231
|
+
const spans = flattenTrace(raw);
|
|
232
|
+
|
|
233
|
+
if (spans.length === 0) {
|
|
234
|
+
return yield* new ObservabilityToolError({
|
|
235
|
+
cause: new Error(`Trace ${traceId} returned zero spans`),
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = {
|
|
240
|
+
success: true,
|
|
241
|
+
message: `Resolved trace ${traceId.toLowerCase()} with ${spans.length} span(s)`,
|
|
242
|
+
data: {
|
|
243
|
+
environment: env,
|
|
244
|
+
grafanaUrl: config.url,
|
|
245
|
+
tempoDatasourceUid: config.tempoUid,
|
|
246
|
+
summary: summarizeTrace(traceId.toLowerCase(), spans),
|
|
247
|
+
spans,
|
|
248
|
+
},
|
|
249
|
+
executionTimeMs: Date.now() - startedAt,
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
yield* Console.log(formatOutput(result, format));
|
|
253
|
+
}).pipe(
|
|
254
|
+
Effect.catch((error) =>
|
|
255
|
+
Effect.gen(function* () {
|
|
256
|
+
const result = {
|
|
257
|
+
success: false,
|
|
258
|
+
message: "Failed to resolve trace from Tempo",
|
|
259
|
+
error: formatObservabilityError(error),
|
|
260
|
+
hint: "Check trace ID format and Grafana/Tempo connectivity",
|
|
261
|
+
executionTimeMs: Date.now() - startedAt,
|
|
262
|
+
};
|
|
263
|
+
yield* Console.log(formatOutput(result, format));
|
|
264
|
+
}),
|
|
265
|
+
),
|
|
266
|
+
);
|
|
267
|
+
},
|
|
268
|
+
).pipe(Command.withDescription("Resolve a trace by ID via Grafana/Tempo"));
|
|
269
|
+
|
|
270
|
+
const logsCommand = Command.make(
|
|
271
|
+
"logs",
|
|
272
|
+
{
|
|
273
|
+
traceId: Argument.string("traceId"),
|
|
274
|
+
format: formatOption,
|
|
275
|
+
env: envOption,
|
|
276
|
+
profile: profileOption,
|
|
277
|
+
limit: Flag.integer("limit").pipe(
|
|
278
|
+
Flag.withDescription("Max log lines (default: 100)"),
|
|
279
|
+
Flag.withDefault(100),
|
|
280
|
+
),
|
|
281
|
+
start: Flag.string("start").pipe(
|
|
282
|
+
Flag.withDescription("Start time (default: now-1h)"),
|
|
283
|
+
Flag.withDefault("now-1h"),
|
|
284
|
+
),
|
|
285
|
+
end: Flag.string("end").pipe(
|
|
286
|
+
Flag.withDescription("End time (default: now)"),
|
|
287
|
+
Flag.withDefault("now"),
|
|
288
|
+
),
|
|
289
|
+
},
|
|
290
|
+
({ traceId, format, env, profile, limit, start, end }) => {
|
|
291
|
+
const startedAt = Date.now();
|
|
292
|
+
|
|
293
|
+
return Effect.gen(function* () {
|
|
294
|
+
if (!isHexTraceId(traceId)) {
|
|
295
|
+
return yield* new ObservabilityToolError({
|
|
296
|
+
cause: new Error("trace logs requires a 32-character hex trace ID"),
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const config = yield* resolveConfig(env, profile);
|
|
301
|
+
const normalizedTraceId = traceId.toLowerCase();
|
|
302
|
+
const logql = `{job=~".+"} |= "${normalizedTraceId}"`;
|
|
303
|
+
const response = yield* observabilityDsQuery(config, config.lokiUid, "loki", logql, {
|
|
304
|
+
from: start,
|
|
305
|
+
to: end,
|
|
306
|
+
maxLines: limit,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
if (response.results.A.error) {
|
|
310
|
+
return yield* new ObservabilityToolError({
|
|
311
|
+
cause: new Error(response.results.A.error),
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const logs: Array<{ timestamp: string; line: string; labels: Record<string, string> }> = [];
|
|
316
|
+
for (const frame of response.results.A.frames ?? []) {
|
|
317
|
+
const fields = frame.schema.fields;
|
|
318
|
+
const values = frame.data.values;
|
|
319
|
+
const timeIndex = fields.findIndex(
|
|
320
|
+
(field) => field.name === "timestamp" || field.type === "time",
|
|
321
|
+
);
|
|
322
|
+
const lineIndex = fields.findIndex(
|
|
323
|
+
(field) => field.name === "body" || field.name === "Line" || field.name === "line",
|
|
324
|
+
);
|
|
325
|
+
const labelsIndex = fields.findIndex(
|
|
326
|
+
(field) => field.name === "labels" || field.name === "labelTypes",
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
const timestamps = (timeIndex >= 0 ? values[timeIndex] : []) as Array<string | number>;
|
|
330
|
+
const lines = (lineIndex >= 0 ? values[lineIndex] : []) as string[];
|
|
331
|
+
const labelValues = (labelsIndex >= 0 ? values[labelsIndex] : []) as Array<
|
|
332
|
+
string | Record<string, string>
|
|
333
|
+
>;
|
|
334
|
+
|
|
335
|
+
for (const [index, line] of lines.entries()) {
|
|
336
|
+
logs.push({
|
|
337
|
+
timestamp: String(timestamps[index] ?? ""),
|
|
338
|
+
line,
|
|
339
|
+
labels: labelValues[index] ? parseLabel(labelValues[index]) : {},
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const result = {
|
|
345
|
+
success: true,
|
|
346
|
+
message: `Found ${logs.length} log line(s) mentioning trace ${normalizedTraceId}`,
|
|
347
|
+
data: {
|
|
348
|
+
environment: env,
|
|
349
|
+
grafanaUrl: config.url,
|
|
350
|
+
lokiDatasourceUid: config.lokiUid,
|
|
351
|
+
query: logql,
|
|
352
|
+
logCount: logs.length,
|
|
353
|
+
logs: logs.toSorted((left, right) => right.timestamp.localeCompare(left.timestamp)),
|
|
354
|
+
},
|
|
355
|
+
executionTimeMs: Date.now() - startedAt,
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
yield* Console.log(formatOutput(result, format));
|
|
359
|
+
}).pipe(
|
|
360
|
+
Effect.catch((error) =>
|
|
361
|
+
Effect.gen(function* () {
|
|
362
|
+
const result = {
|
|
363
|
+
success: false,
|
|
364
|
+
message: "Failed to execute trace log lookup",
|
|
365
|
+
error: formatObservabilityError(error),
|
|
366
|
+
hint: "Check trace ID format and Grafana/Loki connectivity",
|
|
367
|
+
executionTimeMs: Date.now() - startedAt,
|
|
368
|
+
};
|
|
369
|
+
yield* Console.log(formatOutput(result, format));
|
|
370
|
+
}),
|
|
371
|
+
),
|
|
372
|
+
);
|
|
373
|
+
},
|
|
374
|
+
).pipe(Command.withDescription("Find Loki logs mentioning a trace ID"));
|
|
375
|
+
|
|
376
|
+
export const traceCommand = Command.make("trace", {}).pipe(
|
|
377
|
+
Command.withDescription("Tempo trace operations"),
|
|
378
|
+
Command.withSubcommands([getCommand, logsCommand]),
|
|
379
|
+
);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export type ObservabilityEnvConfig = {
|
|
2
|
+
url: string;
|
|
3
|
+
token?: string;
|
|
4
|
+
prometheusUid: string;
|
|
5
|
+
lokiUid: string;
|
|
6
|
+
tempoUid: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type GrafanaDatasource = {
|
|
10
|
+
uid: string;
|
|
11
|
+
name: string;
|
|
12
|
+
type: string;
|
|
13
|
+
url?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type OtlpAnyValue = {
|
|
17
|
+
readonly stringValue?: string;
|
|
18
|
+
readonly boolValue?: boolean;
|
|
19
|
+
readonly intValue?: number | string;
|
|
20
|
+
readonly doubleValue?: number;
|
|
21
|
+
readonly arrayValue?: { readonly values?: readonly OtlpAnyValue[] };
|
|
22
|
+
readonly kvlistValue?: {
|
|
23
|
+
readonly values?: ReadonlyArray<{
|
|
24
|
+
readonly key: string;
|
|
25
|
+
readonly value?: OtlpAnyValue;
|
|
26
|
+
}>;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type OtlpAttribute = {
|
|
31
|
+
readonly key: string;
|
|
32
|
+
readonly value?: OtlpAnyValue;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type TempoTraceResponse = {
|
|
36
|
+
readonly batches?: ReadonlyArray<{
|
|
37
|
+
readonly resource?: {
|
|
38
|
+
readonly attributes?: ReadonlyArray<OtlpAttribute>;
|
|
39
|
+
};
|
|
40
|
+
readonly scopeSpans?: ReadonlyArray<{
|
|
41
|
+
readonly scope?: {
|
|
42
|
+
readonly name?: string;
|
|
43
|
+
};
|
|
44
|
+
readonly spans?: ReadonlyArray<{
|
|
45
|
+
readonly attributes?: ReadonlyArray<OtlpAttribute>;
|
|
46
|
+
readonly endTimeUnixNano?: string;
|
|
47
|
+
readonly kind?: string | number;
|
|
48
|
+
readonly name?: string;
|
|
49
|
+
readonly parentSpanId?: string;
|
|
50
|
+
readonly spanId?: string;
|
|
51
|
+
readonly startTimeUnixNano?: string;
|
|
52
|
+
readonly status?: {
|
|
53
|
+
readonly code?: string | number;
|
|
54
|
+
readonly message?: string;
|
|
55
|
+
};
|
|
56
|
+
readonly traceId?: string;
|
|
57
|
+
}>;
|
|
58
|
+
}>;
|
|
59
|
+
}>;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type FlattenedSpan = {
|
|
63
|
+
readonly serviceName: string;
|
|
64
|
+
readonly scopeName?: string;
|
|
65
|
+
readonly name: string;
|
|
66
|
+
readonly kind?: string | number;
|
|
67
|
+
readonly traceId?: string;
|
|
68
|
+
readonly spanId?: string;
|
|
69
|
+
readonly parentSpanId?: string;
|
|
70
|
+
readonly startTimeUnixNano?: string;
|
|
71
|
+
readonly endTimeUnixNano?: string;
|
|
72
|
+
readonly durationMs?: number;
|
|
73
|
+
readonly statusCode?: string | number;
|
|
74
|
+
readonly statusMessage?: string;
|
|
75
|
+
readonly isError: boolean;
|
|
76
|
+
readonly attributes: Record<string, unknown>;
|
|
77
|
+
readonly resourceAttributes: Record<string, unknown>;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
export type TraceSummary = {
|
|
81
|
+
readonly traceId: string;
|
|
82
|
+
readonly spanCount: number;
|
|
83
|
+
readonly serviceCount: number;
|
|
84
|
+
readonly services: string[];
|
|
85
|
+
readonly errorSpanCount: number;
|
|
86
|
+
readonly rootSpans: FlattenedSpan[];
|
|
87
|
+
readonly totalDurationMs?: number;
|
|
88
|
+
readonly startedAtUnixNano?: string;
|
|
89
|
+
readonly endedAtUnixNano?: string;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export type DsQueryOpts = {
|
|
93
|
+
instant?: boolean;
|
|
94
|
+
from?: string;
|
|
95
|
+
to?: string;
|
|
96
|
+
maxLines?: number;
|
|
97
|
+
intervalMs?: number;
|
|
98
|
+
maxDataPoints?: number;
|
|
99
|
+
step?: number;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export type DsQueryResponse = {
|
|
103
|
+
results: {
|
|
104
|
+
A: {
|
|
105
|
+
status?: number;
|
|
106
|
+
frames?: Array<{
|
|
107
|
+
schema: {
|
|
108
|
+
fields: Array<{
|
|
109
|
+
name: string;
|
|
110
|
+
type?: string;
|
|
111
|
+
labels?: Record<string, string>;
|
|
112
|
+
}>;
|
|
113
|
+
};
|
|
114
|
+
data: { values: unknown[][] };
|
|
115
|
+
}>;
|
|
116
|
+
error?: string;
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
};
|