@blogic-cz/agent-tools 0.12.1 → 0.13.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blogic-cz/agent-tools",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "CLI tools for AI coding agent workflows — GitHub, database, Kubernetes, Azure DevOps, logs, sessions, and audit",
5
5
  "keywords": [
6
6
  "agent",
@@ -1,7 +1,8 @@
1
- import { Console, Effect } from "effect";
1
+ import { Console, Effect, type Option } from "effect";
2
2
  import { Argument, Command, Flag } from "effect/unstable/cli";
3
3
 
4
4
  import { formatOption, formatOutput } from "#shared";
5
+ import type { OutputFormat } from "#shared";
5
6
 
6
7
  import { ObservabilityToolError } from "./errors";
7
8
  import {
@@ -14,14 +15,31 @@ import {
14
15
  } from "./shared";
15
16
  import type {
16
17
  FlattenedSpan,
18
+ ObservabilityEnvConfig,
17
19
  OtlpAnyValue,
18
20
  OtlpAttribute,
21
+ ParsedId,
22
+ SearchWindow,
23
+ SpanResolution,
24
+ TempoSearchResponse,
19
25
  TempoTraceResponse,
20
26
  TraceSummary,
21
27
  } from "./types";
22
28
 
23
- function isHexTraceId(value: string): boolean {
24
- return /^[\da-f]{32}$/i.test(value);
29
+ const SPAN_SEARCH_WINDOWS: SearchWindow[] = [
30
+ { start: "now-1h", end: "now" },
31
+ { start: "now-24h", end: "now" },
32
+ ];
33
+
34
+ function parseId(value: string): ParsedId | undefined {
35
+ const trimmed = value.trim().toLowerCase();
36
+ if (/^[\da-f]{32}$/.test(trimmed)) {
37
+ return { rawId: value, normalizedId: trimmed, kind: "trace_id" };
38
+ }
39
+ if (/^[\da-f]{16}$/.test(trimmed)) {
40
+ return { rawId: value, normalizedId: trimmed, kind: "span_id" };
41
+ }
42
+ return undefined;
25
43
  }
26
44
 
27
45
  function getStringAttribute(
@@ -111,6 +129,16 @@ function computeDurationMs(start?: string, end?: string): number | undefined {
111
129
  return Number(endNano - startNano) / 1_000_000;
112
130
  }
113
131
 
132
+ function relativeToEpoch(value: string, nowEpoch: number): number {
133
+ const match = /^now-(\d+)([smhd])$/.exec(value.trim());
134
+ if (!match) return nowEpoch;
135
+
136
+ const amount = Number(match[1]);
137
+ const unit = match[2];
138
+ const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
139
+ return nowEpoch - amount * (multipliers[unit] ?? 1);
140
+ }
141
+
114
142
  function isErrorStatus(code?: string | number): boolean {
115
143
  if (code === undefined) return false;
116
144
  if (typeof code === "number") return code === 2;
@@ -205,72 +233,207 @@ function parseLabel(value: string | Record<string, string>): Record<string, stri
205
233
  }
206
234
  }
207
235
 
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();
236
+ type ResolvedTrace = {
237
+ readonly resolution: SpanResolution;
238
+ readonly spans: FlattenedSpan[];
239
+ };
240
+
241
+ function searchTempoBySpanId(
242
+ config: ObservabilityEnvConfig,
243
+ spanId: string,
244
+ window: SearchWindow,
245
+ ): Effect.Effect<TempoSearchResponse, ObservabilityToolError> {
246
+ const now = Math.floor(Date.now() / 1000);
247
+ const startEpoch = relativeToEpoch(window.start, now);
248
+ const endEpoch = relativeToEpoch(window.end, now);
249
+ const traceql = encodeURIComponent(`{ span:id = "${spanId}" }`);
250
+ const searchUrl =
251
+ `/api/datasources/proxy/uid/${config.tempoUid}/api/search` +
252
+ `?q=${traceql}&start=${startEpoch}&end=${endEpoch}&limit=5`;
253
+
254
+ return observabilityFetch<TempoSearchResponse>(config, searchUrl);
255
+ }
218
256
 
219
- return Effect.gen(function* () {
220
- if (!isHexTraceId(traceId)) {
257
+ function fetchFullTrace(
258
+ config: ObservabilityEnvConfig,
259
+ traceId: string,
260
+ ): Effect.Effect<FlattenedSpan[], ObservabilityToolError> {
261
+ return Effect.gen(function* () {
262
+ const raw = yield* observabilityFetch<TempoTraceResponse>(
263
+ config,
264
+ `/api/datasources/proxy/uid/${config.tempoUid}/api/traces/${traceId}`,
265
+ );
266
+ return flattenTrace(raw);
267
+ });
268
+ }
269
+
270
+ function resolveTraceFromId(
271
+ config: ObservabilityEnvConfig,
272
+ parsed: ParsedId,
273
+ explicitWindows?: { start: string; end: string },
274
+ ): Effect.Effect<ResolvedTrace, ObservabilityToolError> {
275
+ return Effect.gen(function* () {
276
+ if (parsed.kind === "trace_id") {
277
+ const spans = yield* fetchFullTrace(config, parsed.normalizedId);
278
+ if (spans.length === 0) {
221
279
  return yield* new ObservabilityToolError({
222
- cause: new Error("trace get requires a 32-character hex trace ID"),
280
+ cause: new Error(`Trace ${parsed.normalizedId} returned zero spans`),
223
281
  });
224
282
  }
283
+ return {
284
+ resolution: {
285
+ via: "direct_trace_id" as const,
286
+ resolvedTraceId: parsed.normalizedId,
287
+ },
288
+ spans,
289
+ };
290
+ }
225
291
 
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);
292
+ const windows = explicitWindows
293
+ ? [{ start: explicitWindows.start, end: explicitWindows.end }]
294
+ : SPAN_SEARCH_WINDOWS;
232
295
 
233
- if (spans.length === 0) {
296
+ const attemptedWindows: SearchWindow[] = [];
297
+ let usedWindow: SearchWindow | undefined;
298
+ let uniqueTraceIds: string[] = [];
299
+
300
+ for (const window of windows) {
301
+ attemptedWindows.push(window);
302
+ const searchResult = yield* searchTempoBySpanId(config, parsed.normalizedId, window);
303
+ const traces = searchResult.traces ?? [];
304
+
305
+ if (traces.length === 0) continue;
306
+
307
+ const candidateTraceIds = traces
308
+ .map((trace) => trace.traceID?.toLowerCase())
309
+ .filter((id): id is string => id !== undefined);
310
+
311
+ uniqueTraceIds = [...new Set(candidateTraceIds)];
312
+
313
+ if (uniqueTraceIds.length > 1) {
234
314
  return yield* new ObservabilityToolError({
235
- cause: new Error(`Trace ${traceId} returned zero spans`),
315
+ cause: {
316
+ message: `Ambiguous span ID ${parsed.normalizedId} — found in ${uniqueTraceIds.length} traces`,
317
+ code: "AMBIGUOUS_SPAN_ID",
318
+ retryable: true,
319
+ details: { candidateTraceIds: uniqueTraceIds },
320
+ },
236
321
  });
237
322
  }
238
323
 
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,
324
+ usedWindow = window;
325
+ break;
326
+ }
327
+
328
+ if (uniqueTraceIds.length === 0 || !usedWindow) {
329
+ const windowDesc = attemptedWindows
330
+ .map((window) => `${window.start} → ${window.end}`)
331
+ .join(", ");
332
+ return yield* new ObservabilityToolError({
333
+ cause: new Error(
334
+ `No trace found containing span ${parsed.normalizedId} (searched windows: ${windowDesc})`,
335
+ ),
336
+ });
337
+ }
338
+
339
+ const traceId = uniqueTraceIds[0];
340
+ const spans = yield* fetchFullTrace(config, traceId);
341
+
342
+ if (spans.length === 0) {
343
+ return yield* new ObservabilityToolError({
344
+ cause: new Error(`Trace ${traceId} returned zero spans`),
345
+ });
346
+ }
347
+
348
+ const focusSpan = spans.find((span) => span.spanId === parsed.normalizedId);
349
+
350
+ return {
351
+ resolution: {
352
+ via: "span_search" as const,
353
+ resolvedTraceId: traceId,
354
+ searchedSpanId: parsed.normalizedId,
355
+ focusSpan,
356
+ attemptedWindows,
357
+ usedWindow,
358
+ },
359
+ spans,
360
+ };
361
+ });
362
+ }
363
+
364
+ function handleTraceGet(
365
+ id: string,
366
+ format: OutputFormat,
367
+ env: string,
368
+ profile: Option.Option<string>,
369
+ ) {
370
+ const startedAt = Date.now();
371
+
372
+ return Effect.gen(function* () {
373
+ const parsed = parseId(id);
374
+ if (!parsed) {
375
+ return yield* new ObservabilityToolError({
376
+ cause: {
377
+ message: `Invalid ID format: expected 32-char trace ID or 16-char span ID, got ${id.length} characters`,
378
+ code: "INVALID_ID_FORMAT",
379
+ retryable: false,
248
380
  },
249
- executionTimeMs: Date.now() - startedAt,
250
- };
381
+ });
382
+ }
251
383
 
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
- );
384
+ const config = yield* resolveConfig(env, profile);
385
+ const { resolution, spans } = yield* resolveTraceFromId(config, parsed);
386
+
387
+ const result = {
388
+ success: true,
389
+ message:
390
+ parsed.kind === "span_id"
391
+ ? `Found trace ${resolution.resolvedTraceId} via span ${parsed.normalizedId} with ${spans.length} span(s)`
392
+ : `Resolved trace ${resolution.resolvedTraceId} with ${spans.length} span(s)`,
393
+ data: {
394
+ environment: env,
395
+ grafanaUrl: config.url,
396
+ tempoDatasourceUid: config.tempoUid,
397
+ input: parsed,
398
+ resolution,
399
+ summary: summarizeTrace(resolution.resolvedTraceId, spans),
400
+ spans,
401
+ },
402
+ executionTimeMs: Date.now() - startedAt,
403
+ };
404
+
405
+ yield* Console.log(formatOutput(result, format));
406
+ }).pipe(
407
+ Effect.catch((error) =>
408
+ Effect.gen(function* () {
409
+ const result = {
410
+ success: false,
411
+ message: "Failed to resolve trace from Tempo",
412
+ error: formatObservabilityError(error),
413
+ hint: "Accepts 32-char trace ID or 16-char span ID. Check format and Grafana/Tempo connectivity",
414
+ executionTimeMs: Date.now() - startedAt,
415
+ };
416
+ yield* Console.log(formatOutput(result, format));
417
+ }),
418
+ ),
419
+ );
420
+ }
421
+
422
+ const getCommand = Command.make(
423
+ "get",
424
+ {
425
+ id: Argument.string("id"),
426
+ format: formatOption,
427
+ env: envOption,
428
+ profile: profileOption,
267
429
  },
268
- ).pipe(Command.withDescription("Resolve a trace by ID via Grafana/Tempo"));
430
+ ({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
431
+ ).pipe(Command.withDescription("Resolve a trace by trace ID or span ID via Grafana/Tempo"));
269
432
 
270
433
  const logsCommand = Command.make(
271
434
  "logs",
272
435
  {
273
- traceId: Argument.string("traceId"),
436
+ id: Argument.string("id"),
274
437
  format: formatOption,
275
438
  env: envOption,
276
439
  profile: profileOption,
@@ -287,19 +450,26 @@ const logsCommand = Command.make(
287
450
  Flag.withDefault("now"),
288
451
  ),
289
452
  },
290
- ({ traceId, format, env, profile, limit, start, end }) => {
453
+ ({ id, format, env, profile, limit, start, end }) => {
291
454
  const startedAt = Date.now();
292
455
 
293
456
  return Effect.gen(function* () {
294
- if (!isHexTraceId(traceId)) {
457
+ const parsed = parseId(id);
458
+ if (!parsed) {
295
459
  return yield* new ObservabilityToolError({
296
- cause: new Error("trace logs requires a 32-character hex trace ID"),
460
+ cause: {
461
+ message: `Invalid ID format: expected 32-char trace ID or 16-char span ID, got ${id.length} characters`,
462
+ code: "INVALID_ID_FORMAT",
463
+ retryable: false,
464
+ },
297
465
  });
298
466
  }
299
467
 
300
468
  const config = yield* resolveConfig(env, profile);
301
- const normalizedTraceId = traceId.toLowerCase();
302
- const logql = `{job=~".+"} |= "${normalizedTraceId}"`;
469
+ const { resolution } = yield* resolveTraceFromId(config, parsed, { start, end });
470
+ const resolvedTraceId = resolution.resolvedTraceId;
471
+
472
+ const logql = `{job=~".+"} |= "${resolvedTraceId}"`;
303
473
  const response = yield* observabilityDsQuery(config, config.lokiUid, "loki", logql, {
304
474
  from: start,
305
475
  to: end,
@@ -343,11 +513,16 @@ const logsCommand = Command.make(
343
513
 
344
514
  const result = {
345
515
  success: true,
346
- message: `Found ${logs.length} log line(s) mentioning trace ${normalizedTraceId}`,
516
+ message:
517
+ parsed.kind === "span_id"
518
+ ? `Found ${logs.length} log line(s) for trace ${resolvedTraceId} (resolved from span ${parsed.normalizedId})`
519
+ : `Found ${logs.length} log line(s) mentioning trace ${resolvedTraceId}`,
347
520
  data: {
348
521
  environment: env,
349
522
  grafanaUrl: config.url,
350
523
  lokiDatasourceUid: config.lokiUid,
524
+ input: parsed,
525
+ resolution,
351
526
  query: logql,
352
527
  logCount: logs.length,
353
528
  logs: logs.toSorted((left, right) => right.timestamp.localeCompare(left.timestamp)),
@@ -363,7 +538,7 @@ const logsCommand = Command.make(
363
538
  success: false,
364
539
  message: "Failed to execute trace log lookup",
365
540
  error: formatObservabilityError(error),
366
- hint: "Check trace ID format and Grafana/Loki connectivity",
541
+ hint: "Accepts 32-char trace ID or 16-char span ID. Check format and Grafana/Loki connectivity",
367
542
  executionTimeMs: Date.now() - startedAt,
368
543
  };
369
544
  yield* Console.log(formatOutput(result, format));
@@ -371,9 +546,20 @@ const logsCommand = Command.make(
371
546
  ),
372
547
  );
373
548
  },
374
- ).pipe(Command.withDescription("Find Loki logs mentioning a trace ID"));
549
+ ).pipe(Command.withDescription("Find Loki logs mentioning a trace (accepts trace ID or span ID)"));
550
+
551
+ const findCommand = Command.make(
552
+ "find",
553
+ {
554
+ id: Argument.string("id"),
555
+ format: formatOption,
556
+ env: envOption,
557
+ profile: profileOption,
558
+ },
559
+ ({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
560
+ ).pipe(Command.withDescription("Alias for 'trace get' — resolve a trace by trace ID or span ID"));
375
561
 
376
562
  export const traceCommand = Command.make("trace", {}).pipe(
377
563
  Command.withDescription("Tempo trace operations"),
378
- Command.withSubcommands([getCommand, logsCommand]),
564
+ Command.withSubcommands([getCommand, logsCommand, findCommand]),
379
565
  );
@@ -1,3 +1,5 @@
1
+ import { Schema } from "effect";
2
+
1
3
  export type ObservabilityEnvConfig = {
2
4
  url: string;
3
5
  token?: string;
@@ -89,6 +91,52 @@ export type TraceSummary = {
89
91
  readonly endedAtUnixNano?: string;
90
92
  };
91
93
 
94
+ export type TempoSearchResponse = {
95
+ readonly traces?: ReadonlyArray<{
96
+ readonly traceID?: string;
97
+ readonly rootServiceName?: string;
98
+ readonly rootTraceName?: string;
99
+ readonly startTimeUnixNano?: string;
100
+ readonly durationMs?: number;
101
+ readonly spanSets?: ReadonlyArray<{
102
+ readonly spans?: ReadonlyArray<{
103
+ readonly spanID?: string;
104
+ readonly startTimeUnixNano?: string;
105
+ readonly durationNanos?: string;
106
+ readonly attributes?: ReadonlyArray<OtlpAttribute>;
107
+ }>;
108
+ readonly matched?: number;
109
+ }>;
110
+ }>;
111
+ readonly metrics?: Record<string, unknown>;
112
+ };
113
+
114
+ export const IdKind = Schema.Literals(["trace_id", "span_id"]);
115
+ export type IdKind = typeof IdKind.Type;
116
+
117
+ export const ResolutionVia = Schema.Literals(["direct_trace_id", "span_search"]);
118
+ export type ResolutionVia = typeof ResolutionVia.Type;
119
+
120
+ export type ParsedId = {
121
+ readonly rawId: string;
122
+ readonly normalizedId: string;
123
+ readonly kind: IdKind;
124
+ };
125
+
126
+ export type SearchWindow = {
127
+ readonly start: string;
128
+ readonly end: string;
129
+ };
130
+
131
+ export type SpanResolution = {
132
+ readonly via: ResolutionVia;
133
+ readonly resolvedTraceId: string;
134
+ readonly searchedSpanId?: string;
135
+ readonly focusSpan?: FlattenedSpan;
136
+ readonly attemptedWindows?: ReadonlyArray<SearchWindow>;
137
+ readonly usedWindow?: SearchWindow;
138
+ };
139
+
92
140
  export type DsQueryOpts = {
93
141
  instant?: boolean;
94
142
  from?: string;