@blogic-cz/agent-tools 1.3.0 → 1.4.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 +6 -0
- package/dist/gh-tool/branch.d.ts +2 -2
- package/dist/gh-tool/branch.d.ts.map +1 -1
- package/dist/gh-tool/gist.d.ts +6 -6
- package/dist/gh-tool/gist.d.ts.map +1 -1
- package/dist/gh-tool/issue/commands.d.ts +8 -8
- package/dist/gh-tool/issue/commands.d.ts.map +1 -1
- package/dist/gh-tool/issue/core.d.ts +8 -9
- package/dist/gh-tool/issue/core.d.ts.map +1 -1
- package/dist/gh-tool/issue/triage.d.ts +5 -5
- package/dist/gh-tool/issue/triage.d.ts.map +1 -1
- package/dist/gh-tool/pr/commands.d.ts +36 -36
- package/dist/gh-tool/pr/commands.d.ts.map +1 -1
- package/dist/gh-tool/pr/core.d.ts +13 -13
- package/dist/gh-tool/pr/core.d.ts.map +1 -1
- package/dist/gh-tool/pr/review.d.ts +14 -15
- package/dist/gh-tool/pr/review.d.ts.map +1 -1
- package/dist/gh-tool/pr/stack-read.d.ts +1 -1
- package/dist/gh-tool/pr/stack-read.d.ts.map +1 -1
- package/dist/gh-tool/pr/stack.d.ts +12 -1
- package/dist/gh-tool/pr/stack.d.ts.map +1 -1
- package/dist/gh-tool/release.d.ts +6 -6
- package/dist/gh-tool/release.d.ts.map +1 -1
- package/dist/gh-tool/repo.d.ts +3 -4
- package/dist/gh-tool/repo.d.ts.map +1 -1
- package/dist/gh-tool/service.d.ts +2 -2
- package/dist/gh-tool/service.d.ts.map +1 -1
- package/dist/gh-tool/workflow.d.ts +13 -13
- package/dist/gh-tool/workflow.d.ts.map +1 -1
- package/dist/observability-tool/shared.d.ts.map +1 -1
- package/dist/observability-tool/trace.d.ts +5 -0
- package/dist/observability-tool/trace.d.ts.map +1 -1
- package/dist/observability-tool/types.d.ts +8 -0
- package/dist/observability-tool/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/gh-tool/pr/commands.ts +46 -2
- package/src/gh-tool/pr/core.ts +74 -2
- package/src/gh-tool/pr/stack.ts +45 -0
- package/src/gh-tool/service.ts +2 -2
- package/src/observability-tool/shared.ts +5 -0
- package/src/observability-tool/trace.ts +163 -2
- package/src/observability-tool/types.ts +9 -0
|
@@ -29,7 +29,7 @@ import {
|
|
|
29
29
|
REVIEW_EVENTS,
|
|
30
30
|
} from "#gh/config";
|
|
31
31
|
|
|
32
|
-
import { mergeStack, readStack } from "./stack";
|
|
32
|
+
import { mergeStack, readStack, unstackStack } from "./stack";
|
|
33
33
|
import {
|
|
34
34
|
closePR,
|
|
35
35
|
collectWithStableState,
|
|
@@ -414,6 +414,23 @@ export const fetchReviewTriage = Effect.fn("pr.fetchReviewTriage")(function* (
|
|
|
414
414
|
if (info.reviewDecision !== "" && info.reviewDecision !== "APPROVED") {
|
|
415
415
|
blocking.push(`review=${info.reviewDecision}`);
|
|
416
416
|
}
|
|
417
|
+
|
|
418
|
+
// A member of a GitHub stack cannot merge on its own while open members sit below it, so a
|
|
419
|
+
// verdict that reads only this PR would report ready for a PR nothing can land.
|
|
420
|
+
const stackView = yield* readStack({ pr: info.number }).pipe(
|
|
421
|
+
Effect.catch(() => Effect.succeed(null)),
|
|
422
|
+
);
|
|
423
|
+
const ownPosition = stackView?.members.find((member) => member.number === info.number)?.position;
|
|
424
|
+
const openBelow =
|
|
425
|
+
stackView?.isStacked === true && ownPosition !== undefined
|
|
426
|
+
? stackView.members.filter(
|
|
427
|
+
(member) => member.state === "open" && member.position < ownPosition,
|
|
428
|
+
)
|
|
429
|
+
: [];
|
|
430
|
+
if (openBelow.length > 0) {
|
|
431
|
+
blocking.push(`stack_members_below=${openBelow.map((member) => member.number).join(",")}`);
|
|
432
|
+
}
|
|
433
|
+
|
|
417
434
|
const ready = {
|
|
418
435
|
ready: blocking.length === 0,
|
|
419
436
|
mergeable: info.mergeable,
|
|
@@ -1657,7 +1674,34 @@ const prStackMergeCommand = Command.make(
|
|
|
1657
1674
|
),
|
|
1658
1675
|
);
|
|
1659
1676
|
|
|
1677
|
+
const prStackUnstackCommand = Command.make(
|
|
1678
|
+
"unstack",
|
|
1679
|
+
{
|
|
1680
|
+
confirm: Flag.boolean("confirm").pipe(
|
|
1681
|
+
Flag.withDescription(
|
|
1682
|
+
"Actually unstack (without this flag, only shows what would be removed)",
|
|
1683
|
+
),
|
|
1684
|
+
Flag.withDefault(false),
|
|
1685
|
+
),
|
|
1686
|
+
format: formatOption,
|
|
1687
|
+
pr: Flag.integer("pr").pipe(Flag.withDescription("Any PR in the stack to dissolve")),
|
|
1688
|
+
repo: repoOption,
|
|
1689
|
+
},
|
|
1690
|
+
({ confirm, format, pr, repo }) =>
|
|
1691
|
+
withRepo(
|
|
1692
|
+
repo,
|
|
1693
|
+
Effect.gen(function* () {
|
|
1694
|
+
const result = yield* unstackStack({ confirm, pr });
|
|
1695
|
+
yield* logFormatted(result, format);
|
|
1696
|
+
}),
|
|
1697
|
+
),
|
|
1698
|
+
).pipe(
|
|
1699
|
+
Command.withDescription(
|
|
1700
|
+
"Remove every unmerged PR from the stack, dissolving it (dry-run by default)",
|
|
1701
|
+
),
|
|
1702
|
+
);
|
|
1703
|
+
|
|
1660
1704
|
export const prStackCommand = Command.make("stack", {}).pipe(
|
|
1661
|
-
Command.withSubcommands([prStackViewCommand, prStackMergeCommand]),
|
|
1705
|
+
Command.withSubcommands([prStackViewCommand, prStackMergeCommand, prStackUnstackCommand]),
|
|
1662
1706
|
Command.withDescription("Stacked pull request operations"),
|
|
1663
1707
|
);
|
package/src/gh-tool/pr/core.ts
CHANGED
|
@@ -19,6 +19,7 @@ import type {
|
|
|
19
19
|
import type { GitHubAuthError, GitHubNotFoundError } from "#gh/errors";
|
|
20
20
|
import { GitHubCommandError, GitHubMergeError } from "#gh/errors";
|
|
21
21
|
import { GitHubService } from "#gh/service";
|
|
22
|
+
import type { GhResult } from "#gh/service";
|
|
22
23
|
import { logText } from "#shared";
|
|
23
24
|
|
|
24
25
|
import type { ButStatusJson, PRViewJsonResult } from "./helpers";
|
|
@@ -201,6 +202,13 @@ const fetchWorkflowRunFailureContext = Effect.fn("pr.fetchWorkflowRunFailureCont
|
|
|
201
202
|
// `gh pr checks` exits 1 on an *empty* result ("no checks reported on the 'x' branch"). Zero checks
|
|
202
203
|
// is an ordinary state, so map it to [] and keep the zero-check paths downstream reachable.
|
|
203
204
|
export const NO_CHECKS_REPORTED_RE = /no checks reported/i;
|
|
205
|
+
// `gh` reports "no checks reported" both for the seconds after a push, before its checks
|
|
206
|
+
// register, and forever for a PR that has none — the message cannot tell them apart. So the
|
|
207
|
+
// wait for registration gets its own short budget: long enough to cover the race (measured
|
|
208
|
+
// at roughly 40s on blogic-cz/agent-tools#134), short enough that a check-less PR is not held
|
|
209
|
+
// for the caller's whole --timeout.
|
|
210
|
+
const CHECK_REGISTRATION_POLL_SECONDS = 5;
|
|
211
|
+
const CHECK_REGISTRATION_GRACE_SECONDS = 60;
|
|
204
212
|
|
|
205
213
|
export const fetchCheckResults = Effect.fn("pr.fetchCheckResults")(function* (pr: number | null) {
|
|
206
214
|
const gh = yield* GitHubService;
|
|
@@ -1181,6 +1189,24 @@ export const editPR = Effect.fn("pr.editPR")(function* (opts: {
|
|
|
1181
1189
|
|
|
1182
1190
|
const repo = yield* gh.getRepoInfo();
|
|
1183
1191
|
|
|
1192
|
+
// GitHub rejects a base change on a stacked PR with a bare 422. Say what the state is and
|
|
1193
|
+
// which operation changes it, rather than letting the validation error through.
|
|
1194
|
+
if (opts.base !== null) {
|
|
1195
|
+
const stackView = yield* readStack({ pr: opts.pr }).pipe(
|
|
1196
|
+
Effect.catch(() => Effect.succeed(null)),
|
|
1197
|
+
);
|
|
1198
|
+
if (stackView?.isStacked === true) {
|
|
1199
|
+
return yield* new GitHubCommandError({
|
|
1200
|
+
command: "pr edit --base",
|
|
1201
|
+
exitCode: 1,
|
|
1202
|
+
stderr: `PR #${opts.pr} belongs to GitHub stack #${stackView.stackNumber}`,
|
|
1203
|
+
message: `Cannot retarget PR #${opts.pr}: it belongs to GitHub stack #${stackView.stackNumber}, whose members' bases GitHub owns`,
|
|
1204
|
+
hint: "Dissolve the stack first with 'pr stack unstack', which removes every unmerged member, then retarget. Merging the stack instead needs no retarget at all.",
|
|
1205
|
+
nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1184
1210
|
const editArgs = [
|
|
1185
1211
|
"api",
|
|
1186
1212
|
"--method",
|
|
@@ -1226,7 +1252,45 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
1226
1252
|
// Block for the caller's requested --timeout (no artificial cap — blocking isn't the problem;
|
|
1227
1253
|
// --timeout is validated >= 1s at the CLI boundary). On timeout return a snapshot, never
|
|
1228
1254
|
// nothing — that was the actual token-wasting bug.
|
|
1229
|
-
|
|
1255
|
+
// A push registers its checks a moment after the ref lands, and `gh pr checks --watch`
|
|
1256
|
+
// exits non-zero in that window. A watch was asked to wait, so keep waiting: the outer
|
|
1257
|
+
// timeout still bounds it, and a PR that genuinely has no checks returns an empty
|
|
1258
|
+
// snapshot at that deadline rather than an error.
|
|
1259
|
+
let watchResult: GhResult | null = null;
|
|
1260
|
+
let registered = false;
|
|
1261
|
+
let graceExpired = false;
|
|
1262
|
+
const watchThroughRegistration = Effect.gen(function* () {
|
|
1263
|
+
const graceStart = yield* Clock.currentTimeMillis;
|
|
1264
|
+
const graceDeadlineMs =
|
|
1265
|
+
Number(graceStart) + Math.min(CHECK_REGISTRATION_GRACE_SECONDS, timeoutSeconds) * 1000;
|
|
1266
|
+
yield* Effect.whileLoop({
|
|
1267
|
+
while: () => !registered && !graceExpired,
|
|
1268
|
+
body: () =>
|
|
1269
|
+
gh.runGh(watchArgs).pipe(
|
|
1270
|
+
Effect.flatMap((result) => {
|
|
1271
|
+
watchResult = result;
|
|
1272
|
+
registered = true;
|
|
1273
|
+
return Effect.void;
|
|
1274
|
+
}),
|
|
1275
|
+
Effect.catchTag("GitHubCommandError", (error) =>
|
|
1276
|
+
NO_CHECKS_REPORTED_RE.test(error.stderr) || NO_CHECKS_REPORTED_RE.test(error.message)
|
|
1277
|
+
? Effect.gen(function* () {
|
|
1278
|
+
const now = yield* Clock.currentTimeMillis;
|
|
1279
|
+
if (Number(now) >= graceDeadlineMs) {
|
|
1280
|
+
graceExpired = true;
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
yield* Effect.sleep(Duration.seconds(CHECK_REGISTRATION_POLL_SECONDS));
|
|
1284
|
+
})
|
|
1285
|
+
: Effect.fail(error),
|
|
1286
|
+
),
|
|
1287
|
+
),
|
|
1288
|
+
step: () => undefined,
|
|
1289
|
+
});
|
|
1290
|
+
return watchResult;
|
|
1291
|
+
});
|
|
1292
|
+
|
|
1293
|
+
const watchOutcome = yield* watchThroughRegistration.pipe(
|
|
1230
1294
|
Effect.timeoutOrElse({
|
|
1231
1295
|
duration: timeoutSeconds * 1000,
|
|
1232
1296
|
orElse: () => Effect.succeed(null),
|
|
@@ -1234,7 +1298,15 @@ export const fetchChecks = Effect.fn("pr.fetchChecks")(function* (
|
|
|
1234
1298
|
);
|
|
1235
1299
|
|
|
1236
1300
|
const results = yield* fetchCheckResults(pr);
|
|
1237
|
-
if (!quiet &&
|
|
1301
|
+
if (!quiet && graceExpired) {
|
|
1302
|
+
const prRef = pr === null ? "<number>" : String(pr);
|
|
1303
|
+
yield* Console.warn(
|
|
1304
|
+
`ℹ️ No checks registered within ${CHECK_REGISTRATION_GRACE_SECONDS}s of watching; ` +
|
|
1305
|
+
`returning the current snapshot. If this PR should have checks, the push event was ` +
|
|
1306
|
+
`likely dropped, and re-watching will find nothing again — dispatch them on the PR ` +
|
|
1307
|
+
`head instead:\n agent-tools-gh pr trigger-checks --pr ${prRef} --workflow <file.yml>`,
|
|
1308
|
+
);
|
|
1309
|
+
} else if (!quiet && watchOutcome === null && results.some((c) => c.bucket === "pending")) {
|
|
1238
1310
|
const pending = results.filter((c) => c.bucket === "pending").length;
|
|
1239
1311
|
yield* Console.warn(
|
|
1240
1312
|
`ℹ️ Watch timed out after ${timeoutSeconds}s; ${pending} check(s) still pending (snapshot returned). ` +
|
package/src/gh-tool/pr/stack.ts
CHANGED
|
@@ -237,3 +237,48 @@ export const mergeStack = Effect.fn("pr.mergeStack")(function* (opts: {
|
|
|
237
237
|
hint: "Inspect the stack state and branch protections, then retry.",
|
|
238
238
|
});
|
|
239
239
|
});
|
|
240
|
+
|
|
241
|
+
export const unstackStack = Effect.fn("pr.unstackStack")(function* (opts: {
|
|
242
|
+
pr: number;
|
|
243
|
+
confirm: boolean;
|
|
244
|
+
}) {
|
|
245
|
+
const gh = yield* GitHubService;
|
|
246
|
+
const repo = yield* gh.getRepoInfo();
|
|
247
|
+
const view = yield* readStack({ pr: opts.pr });
|
|
248
|
+
|
|
249
|
+
if (!view.isStacked || view.stackNumber === null) {
|
|
250
|
+
return yield* new GitHubMergeError({
|
|
251
|
+
message: `PR #${opts.pr} is not part of a GitHub stack`,
|
|
252
|
+
reason: "unknown",
|
|
253
|
+
hint: "There is no stack to dissolve.",
|
|
254
|
+
nextCommand: `agent-tools-gh pr stack view --pr ${opts.pr}`,
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const unmerged = view.members.filter((member) => member.state === "open");
|
|
259
|
+
|
|
260
|
+
const plan = {
|
|
261
|
+
stackNumber: view.stackNumber,
|
|
262
|
+
pr: opts.pr,
|
|
263
|
+
removes: unmerged.map((member) => member.number),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
if (!opts.confirm) {
|
|
267
|
+
return { ...plan, dissolved: false, unstacked: false, dryRun: true };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// The endpoint removes every unmerged member at once; it has no per-PR form. A 204 means
|
|
271
|
+
// nothing was left and the stack is gone, a 200 means some members could not be removed.
|
|
272
|
+
const result = yield* gh.apiRequest<unknown>({
|
|
273
|
+
path: `repos/${repo.owner}/${repo.name}/stacks/${view.stackNumber}/unstack`,
|
|
274
|
+
method: "POST",
|
|
275
|
+
alsoAcceptStatus: [204],
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
return {
|
|
279
|
+
...plan,
|
|
280
|
+
dissolved: result.status === 204,
|
|
281
|
+
unstacked: true,
|
|
282
|
+
dryRun: false,
|
|
283
|
+
};
|
|
284
|
+
});
|
package/src/gh-tool/service.ts
CHANGED
|
@@ -68,13 +68,13 @@ const isSafeRetryRead = (args: readonly string[]): boolean => {
|
|
|
68
68
|
return args.some((a) => READ_VERBS.has(a));
|
|
69
69
|
};
|
|
70
70
|
|
|
71
|
-
type GhResult = {
|
|
71
|
+
export type GhResult = {
|
|
72
72
|
stdout: string;
|
|
73
73
|
stderr: string;
|
|
74
74
|
exitCode: number;
|
|
75
75
|
};
|
|
76
76
|
|
|
77
|
-
type GhError = GitHubCommandError | GitHubAuthError | GitHubNotFoundError;
|
|
77
|
+
export type GhError = GitHubCommandError | GitHubAuthError | GitHubNotFoundError;
|
|
78
78
|
|
|
79
79
|
export class GitHubService extends Context.Service<
|
|
80
80
|
GitHubService,
|
|
@@ -26,6 +26,11 @@ export function formatObservabilityError(error: unknown): string {
|
|
|
26
26
|
return error.message;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
// Structured causes carry their text in `message`; String() would render "[object Object]".
|
|
30
|
+
if (typeof error === "object" && error !== null && "message" in error) {
|
|
31
|
+
return String((error as { message: unknown }).message);
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
return String(error);
|
|
30
35
|
}
|
|
31
36
|
|
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
SpanResolution,
|
|
26
26
|
TempoSearchResponse,
|
|
27
27
|
TempoTraceResponse,
|
|
28
|
+
TraceSearchHit,
|
|
28
29
|
TraceSummary,
|
|
29
30
|
} from "./types";
|
|
30
31
|
|
|
@@ -355,6 +356,95 @@ function resolveTraceFromId(
|
|
|
355
356
|
});
|
|
356
357
|
}
|
|
357
358
|
|
|
359
|
+
/**
|
|
360
|
+
* Strict counterpart of relativeToEpoch for user-supplied windows: an unparseable bound is
|
|
361
|
+
* refused rather than silently collapsed to "now", which would report a zero-width window as
|
|
362
|
+
* "no traces found".
|
|
363
|
+
*/
|
|
364
|
+
function strictRelativeToEpoch(value: string, nowEpoch: number): number | undefined {
|
|
365
|
+
const trimmed = value.trim();
|
|
366
|
+
if (trimmed === "now") return nowEpoch;
|
|
367
|
+
return /^now-\d+[smhd]$/.test(trimmed) ? relativeToEpoch(trimmed, nowEpoch) : undefined;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Tempo rejects a search window wider than this, so the CLI says so before the API does. */
|
|
371
|
+
const MAX_SEARCH_RANGE_SECONDS = 168 * 3600;
|
|
372
|
+
|
|
373
|
+
export function summarizeSearchHits(response: TempoSearchResponse): TraceSearchHit[] {
|
|
374
|
+
return (response.traces ?? [])
|
|
375
|
+
.filter((trace) => trace.traceID !== undefined)
|
|
376
|
+
.map((trace) => {
|
|
377
|
+
const startedNano = nanoToBigInt(trace.startTimeUnixNano);
|
|
378
|
+
|
|
379
|
+
return {
|
|
380
|
+
traceId: trace.traceID as string,
|
|
381
|
+
startedAt:
|
|
382
|
+
startedNano === undefined
|
|
383
|
+
? undefined
|
|
384
|
+
: new Date(Number(startedNano / 1_000_000n)).toISOString(),
|
|
385
|
+
rootServiceName: trace.rootServiceName,
|
|
386
|
+
rootTraceName: trace.rootTraceName,
|
|
387
|
+
durationMs: trace.durationMs,
|
|
388
|
+
matchedSpans: trace.spanSets?.reduce(
|
|
389
|
+
(total, spanSet) => total + (spanSet.matched ?? spanSet.spans?.length ?? 0),
|
|
390
|
+
0,
|
|
391
|
+
),
|
|
392
|
+
};
|
|
393
|
+
})
|
|
394
|
+
.toSorted((left, right) => (right.startedAt ?? "").localeCompare(left.startedAt ?? ""));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function searchTempoByQuery(
|
|
398
|
+
config: ObservabilityEnvConfig,
|
|
399
|
+
query: string,
|
|
400
|
+
window: SearchWindow,
|
|
401
|
+
limit: number,
|
|
402
|
+
): Effect.Effect<TempoSearchResponse, ObservabilityToolError> {
|
|
403
|
+
return Effect.gen(function* () {
|
|
404
|
+
const tempoUid = yield* requireTempoUid(config);
|
|
405
|
+
const now = Math.floor(Date.now() / 1000);
|
|
406
|
+
const startEpoch = strictRelativeToEpoch(window.start, now);
|
|
407
|
+
const endEpoch = strictRelativeToEpoch(window.end, now);
|
|
408
|
+
|
|
409
|
+
if (startEpoch === undefined || endEpoch === undefined) {
|
|
410
|
+
const rejected = startEpoch === undefined ? window.start : window.end;
|
|
411
|
+
return yield* new ObservabilityToolError({
|
|
412
|
+
cause: {
|
|
413
|
+
message: `Unparseable time "${rejected}" — use "now" or "now-<number><s|m|h|d>", e.g. now-6h`,
|
|
414
|
+
code: "INVALID_TIME_RANGE",
|
|
415
|
+
retryable: false,
|
|
416
|
+
},
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (startEpoch >= endEpoch) {
|
|
421
|
+
return yield* new ObservabilityToolError({
|
|
422
|
+
cause: {
|
|
423
|
+
message: `Search window ${window.start} → ${window.end} ends at or before it starts`,
|
|
424
|
+
code: "INVALID_TIME_RANGE",
|
|
425
|
+
retryable: false,
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (endEpoch - startEpoch > MAX_SEARCH_RANGE_SECONDS) {
|
|
431
|
+
return yield* new ObservabilityToolError({
|
|
432
|
+
cause: {
|
|
433
|
+
message: `Search window ${window.start} → ${window.end} exceeds the 168h Tempo limit — query a narrower range`,
|
|
434
|
+
code: "SEARCH_RANGE_TOO_WIDE",
|
|
435
|
+
retryable: false,
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const searchUrl =
|
|
441
|
+
`/api/datasources/proxy/uid/${tempoUid}/api/search` +
|
|
442
|
+
`?q=${encodeURIComponent(query)}&start=${startEpoch}&end=${endEpoch}&limit=${limit}`;
|
|
443
|
+
|
|
444
|
+
return yield* observabilityFetch<TempoSearchResponse>(config, searchUrl);
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
358
448
|
function handleTraceGet(
|
|
359
449
|
id: string,
|
|
360
450
|
format: OutputFormat,
|
|
@@ -524,9 +614,80 @@ const findCommand = Command.make(
|
|
|
524
614
|
profile: profileOption,
|
|
525
615
|
},
|
|
526
616
|
({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
|
|
527
|
-
).pipe(
|
|
617
|
+
).pipe(
|
|
618
|
+
Command.withDescription(
|
|
619
|
+
"Alias for 'trace get' — resolves an id, and is not the TraceQL search; that is 'trace search'",
|
|
620
|
+
),
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
const searchCommand = Command.make(
|
|
624
|
+
"search",
|
|
625
|
+
{
|
|
626
|
+
query: Argument.string("query"),
|
|
627
|
+
format: formatOption,
|
|
628
|
+
env: envOption,
|
|
629
|
+
profile: profileOption,
|
|
630
|
+
limit: Flag.integer("limit").pipe(
|
|
631
|
+
Flag.withDescription("Max traces to return (default: 20)"),
|
|
632
|
+
Flag.withDefault(20),
|
|
633
|
+
),
|
|
634
|
+
start: Flag.string("start").pipe(
|
|
635
|
+
Flag.withDescription("Start time (default: now-1h, max span now-168h)"),
|
|
636
|
+
Flag.withDefault("now-1h"),
|
|
637
|
+
),
|
|
638
|
+
end: Flag.string("end").pipe(
|
|
639
|
+
Flag.withDescription("End time (default: now)"),
|
|
640
|
+
Flag.withDefault("now"),
|
|
641
|
+
),
|
|
642
|
+
},
|
|
643
|
+
({ query, format, env, profile, limit, start, end }) => {
|
|
644
|
+
const startedAt = Date.now();
|
|
645
|
+
|
|
646
|
+
return Effect.gen(function* () {
|
|
647
|
+
const config = yield* resolveConfig(env, profile);
|
|
648
|
+
const response = yield* searchTempoByQuery(config, query, { start, end }, limit);
|
|
649
|
+
const traces = summarizeSearchHits(response);
|
|
650
|
+
|
|
651
|
+
const result = {
|
|
652
|
+
success: true,
|
|
653
|
+
message: `Found ${traces.length} trace(s) matching the TraceQL query`,
|
|
654
|
+
data: {
|
|
655
|
+
environment: env,
|
|
656
|
+
grafanaUrl: config.url,
|
|
657
|
+
tempoDatasourceUid: config.tempoUid ?? null,
|
|
658
|
+
query,
|
|
659
|
+
start,
|
|
660
|
+
end,
|
|
661
|
+
limit,
|
|
662
|
+
traceCount: traces.length,
|
|
663
|
+
traces,
|
|
664
|
+
},
|
|
665
|
+
executionTimeMs: Date.now() - startedAt,
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
yield* logText(formatOutput(result, format));
|
|
669
|
+
}).pipe(
|
|
670
|
+
Effect.catch((error) =>
|
|
671
|
+
Effect.gen(function* () {
|
|
672
|
+
const result = {
|
|
673
|
+
success: false,
|
|
674
|
+
message: "Failed to search Tempo",
|
|
675
|
+
error: formatObservabilityError(error),
|
|
676
|
+
hint: 'Query is TraceQL, e.g. { name = "GraphQL Document Validation" && status = error }. The window may not exceed 168h',
|
|
677
|
+
executionTimeMs: Date.now() - startedAt,
|
|
678
|
+
};
|
|
679
|
+
yield* logText(formatOutput(result, format));
|
|
680
|
+
}),
|
|
681
|
+
),
|
|
682
|
+
);
|
|
683
|
+
},
|
|
684
|
+
).pipe(
|
|
685
|
+
Command.withDescription(
|
|
686
|
+
"Search Tempo with a TraceQL query — find traces when no trace or span ID is known yet",
|
|
687
|
+
),
|
|
688
|
+
);
|
|
528
689
|
|
|
529
690
|
export const traceCommand = Command.make("trace", {}).pipe(
|
|
530
691
|
Command.withDescription("Tempo trace operations"),
|
|
531
|
-
Command.withSubcommands([getCommand, logsCommand, findCommand]),
|
|
692
|
+
Command.withSubcommands([getCommand, searchCommand, logsCommand, findCommand]),
|
|
532
693
|
);
|
|
@@ -127,6 +127,15 @@ export type TempoSearchResponse = {
|
|
|
127
127
|
readonly metrics?: Record<string, unknown>;
|
|
128
128
|
};
|
|
129
129
|
|
|
130
|
+
export type TraceSearchHit = {
|
|
131
|
+
readonly traceId: string;
|
|
132
|
+
readonly startedAt?: string;
|
|
133
|
+
readonly rootServiceName?: string;
|
|
134
|
+
readonly rootTraceName?: string;
|
|
135
|
+
readonly durationMs?: number;
|
|
136
|
+
readonly matchedSpans?: number;
|
|
137
|
+
};
|
|
138
|
+
|
|
130
139
|
export const IdKind = Schema.Literals(["trace_id", "span_id"]);
|
|
131
140
|
export type IdKind = typeof IdKind.Type;
|
|
132
141
|
|