From 67467db01168301ce7ff0b820a806971e24668f5 Mon Sep 17 00:00:00 2001 From: Jibaru Date: Thu, 12 Nov 2026 16:01:25 -0500 Subject: [PATCH] feat(logs): add `clerk logs get ` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Show the logs of one log group (a sign-in `sia_…` or sign-up `sua_…`), oldest first, from GET /v1/logs/groups/{group_id}/events without started_at, which the Backend API now looks up from the group's ID. It takes the list's event filters, --limit (1-500, the endpoint's max) and --cursor, and prints a { data, cursor } envelope with --json. An unknown group is a log_group_not_found error naming the targeted instance. A log ID (UUID) is recognized but refused until GET /v1/logs/{id} accepts a bare log ID (CORE-4354). Refs CORE-4344. Co-Authored-By: Claude Opus 5.5 --- .changeset/clerk-logs-get.md | 5 + packages/cli-core/src/commands/logs/README.md | 58 ++- .../cli-core/src/commands/logs/gate.test.ts | 9 +- .../cli-core/src/commands/logs/get.test.ts | 398 ++++++++++++++++++ packages/cli-core/src/commands/logs/get.ts | 259 ++++++++++++ packages/cli-core/src/commands/logs/index.ts | 65 ++- packages/cli-core/src/commands/logs/list.ts | 3 +- packages/cli-core/src/lib/errors.ts | 2 + test/e2e/lib/fapi-sign-up.ts | 114 +++++ test/e2e/logs-get.test.ts | 146 +++++++ 10 files changed, 1049 insertions(+), 10 deletions(-) create mode 100644 .changeset/clerk-logs-get.md create mode 100644 packages/cli-core/src/commands/logs/get.test.ts create mode 100644 packages/cli-core/src/commands/logs/get.ts create mode 100644 test/e2e/lib/fapi-sign-up.ts create mode 100644 test/e2e/logs-get.test.ts diff --git a/.changeset/clerk-logs-get.md b/.changeset/clerk-logs-get.md new file mode 100644 index 000000000..c46ffd4f0 --- /dev/null +++ b/.changeset/clerk-logs-get.md @@ -0,0 +1,5 @@ +--- +"clerk": minor +--- + +Add `clerk logs get ` to show the logs of one sign-in (`sia_…`) or sign-up (`sua_…`), oldest first, under a header with the flow's type, start and subject. It takes the same event filters as `clerk logs list`, `--limit` (up to 500) and `--cursor` to page forward, and `--json` for a `{ data, cursor }` envelope. An unknown ID fails with `log_group_not_found`; looking up a single log by its ID isn't supported yet. `clerk logs` is still experimental and needs `CLERK_EXPERIMENTAL=logs`. diff --git a/packages/cli-core/src/commands/logs/README.md b/packages/cli-core/src/commands/logs/README.md index 684ed106c..a747ac97f 100644 --- a/packages/cli-core/src/commands/logs/README.md +++ b/packages/cli-core/src/commands/logs/README.md @@ -149,6 +149,55 @@ TIME TYPE ACTOR SUBJEC 15:30:00 user.created user_2abc… user_2abc… Lima, PE log_2xyz… ``` +### `clerk logs get ` + +Show the logs of one log group, oldest first. A log group is one flow: a sign-in (`sia_…`) or a sign-up (`sua_…`), and the flow's ID is the group's ID. + +```sh +clerk logs get sia_123 +clerk logs get sua_123 --json +clerk logs get sia_123 --type 'sign_in.*' +clerk logs get sia_123 --cursor +clerk logs get sia_123 --app app_123 --instance prod +``` + +| Option | Description | +| ------------------- | ----------------------------------------------- | +| `--limit ` | Maximum logs to return, 1-500 (default 50) | +| `--cursor ` | Continue from the cursor a previous run printed | +| Filter flags | The same as `list`; see [Filters](#filters) | +| `--json` | Output as JSON (implied in agent mode) | + +- The ID is checked before any request. A sign-in or sign-up ID reads the group's logs. A log ID (a UUID) is a usage error for now, since looking up a single log by its ID isn't supported yet; anything else is a usage error that lists the accepted forms. +- The request sends no `started_at`: the Backend API looks the group's start up from its ID. A group it can't find in the targeted instance (an unknown ID, another instance's group, or one entirely past the plan's log retention) fails with `log_group_not_found`, naming the instance the request went to. +- The event filters work as in `list` and are checked the same way. `get` has no time range flags, `--follow`, or group filters: the group is already chosen, and the endpoint picks a group's logs by when the group started, not by each log's time. +- The endpoint returns full pages (it doesn't scan time windows like `GET /v1/logs`), so one request covers `--limit`. When the group has more logs, `cursor.next` is set; pass it to `--cursor` to page forward. Only forward paging is supported. + +Human output is a header with the group type and ID, then when the group started and its subject (the latest user any of the fetched logs is about, or the first log's subject when none is), the same table as `list`, and the count with the `--cursor` to continue from when there are more logs. The endpoint doesn't return the group's status, so the header leaves it out. + +```text +sign_up sua_2xYzAbC123 +Started 15:56:21 · Subject user_2def + +TIME TYPE ACTOR SUBJECT LOCATION LOG ID +15:56:21 sign_up.created - ins_2abc Lima, PE 019d8e31-a1bc-… +15:56:25 sign_up.verification_attempted - ins_2abc Lima, PE 019d8e31-b2cd-… +15:56:27 sign_up.completed - user_2def Lima, PE 019d8e31-c3de-… + +3 logs · more: re-run with `--cursor ` +``` + +JSON output has the same `{ data, cursor }` shape as `list`. `cursor` carries only `next`, since the endpoint doesn't search a time window or report retention: + +```json +{ + "data": [ + { "object": "log", "id": "…", "type": "sign_up.created", "group_id": "sua_…", "…": "…" } + ], + "cursor": { "next": null } +} +``` + ### `clerk logs schema ` Show the payload fields of a log event type: the dot-paths, their types, whether each is optional, and the accepted values of enum fields. @@ -189,7 +238,8 @@ JSON output is the `log_schema` object exactly as the Backend API returns it: ## API endpoints -| Method | Path | Description | -| ------ | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/logs` | List logs (`view=flat`, `limit`, `starting_after`, `event_time_after`, `event_time_before`, `type`, `user_id`, `identifier`, `actor`, `subject`, `session_id`, `client_id`, `ip_address`, `trace_id`, `impersonator_user_id`, `payload_filter[]`, `payload_fields`, `filter_match`, `end_user_facing_only`), newest first | -| `GET` | `/v1/logs/schemas` | Payload schema of an event type (`type`) | +| Method | Path | Description | +| ------ | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/logs` | List logs (`view=flat`, `limit`, `starting_after`, `event_time_after`, `event_time_before`, `type`, `user_id`, `identifier`, `actor`, `subject`, `session_id`, `client_id`, `ip_address`, `trace_id`, `impersonator_user_id`, `payload_filter[]`, `payload_fields`, `filter_match`, `end_user_facing_only`), newest first | +| `GET` | `/v1/logs/groups/{group_id}/events` | A log group's logs, oldest first (`limit`, `starting_after` and the list's event filters; no `started_at`) | +| `GET` | `/v1/logs/schemas` | Payload schema of an event type (`type`) | diff --git a/packages/cli-core/src/commands/logs/gate.test.ts b/packages/cli-core/src/commands/logs/gate.test.ts index 89a0acf53..57466e44e 100644 --- a/packages/cli-core/src/commands/logs/gate.test.ts +++ b/packages/cli-core/src/commands/logs/gate.test.ts @@ -35,6 +35,7 @@ describe("with CLERK_EXPERIMENTAL unset", () => { [["logs", "list", "--help"]], [["logs", "list", "--json", "--limit", "10"]], [["logs", "schema", "sign_in.completed"]], + [["logs", "get", "sia_123"]], [["logs", "--not-a-flag"]], [["help", "logs"]], [["--verbose", "logs", "list"]], @@ -57,15 +58,15 @@ describe("with CLERK_EXPERIMENTAL=logs", () => { expect(createProgram().helpInformation()).toMatch(/^\s+logs\b/m); }); - test("the real list and schema subcommands are registered", () => { + test("the real list, get and schema subcommands are registered", () => { process.env.CLERK_EXPERIMENTAL = "logs"; const logs = createProgram().commands.find((c) => c.name() === "logs"); - expect(logs?.commands.map((c) => c.name())).toEqual(["list", "schema"]); + expect(logs?.commands.map((c) => c.name())).toEqual(["list", "get", "schema"]); }); - test("completion offers list and schema under logs", () => { + test("completion offers list, get and schema under logs", () => { process.env.CLERK_EXPERIMENTAL = "logs"; const names = generateCompletions(createProgram(), ["logs", ""]).completions.map((c) => c.name); - expect(names).toEqual(expect.arrayContaining(["list", "schema"])); + expect(names).toEqual(expect.arrayContaining(["list", "get", "schema"])); }); }); diff --git a/packages/cli-core/src/commands/logs/get.test.ts b/packages/cli-core/src/commands/logs/get.test.ts new file mode 100644 index 000000000..db4012afd --- /dev/null +++ b/packages/cli-core/src/commands/logs/get.test.ts @@ -0,0 +1,398 @@ +import { test, expect, describe, beforeEach, afterEach, mock } from "bun:test"; +import { BapiError, CliError } from "../../lib/errors.ts"; +import { useCaptureLog } from "../../test/lib/stubs.ts"; + +const mockBapiRequest = mock(); +mock.module("../../lib/bapi.ts", () => ({ + bapiRequest: (...args: unknown[]) => mockBapiRequest(...args), +})); + +const mockResolveBapiSecretKey = mock(); +const mockDescribeBapiTarget = mock(); +mock.module("../../lib/bapi-command.ts", () => ({ + resolveBapiSecretKey: (...args: unknown[]) => mockResolveBapiSecretKey(...args), + describeBapiTarget: (...args: unknown[]) => mockDescribeBapiTarget(...args), +})); + +const mockWithSpinner = mock((_msg: string, fn: () => Promise) => fn()); +mock.module("../../lib/spinner.ts", () => ({ + intro: () => {}, + outro: () => {}, + pausedOutro: () => {}, + bar: () => {}, + withSpinner: (...args: Parameters) => mockWithSpinner(...args), +})); + +const mockIsAgent = mock(); +mock.module("../../mode.ts", () => ({ + isAgent: (...args: unknown[]) => mockIsAgent(...args), + isHuman: (...args: unknown[]) => !mockIsAgent(...args), + setMode: () => {}, + getMode: () => "human", +})); + +const { get, parseLogLookup, groupSubject } = await import("./get.ts"); + +const ANSI_ESCAPE_PATTERN = new RegExp(String.raw`\x1b\[[0-9;]*m`, "g"); +const GROUP_ID = "sua_2xYzAbC123"; +const STARTED_AT = Date.UTC(2026, 10, 9, 15, 30, 0); + +function makeLog(id: string, overrides: Record = {}) { + return { + object: "log", + id, + event_time: STARTED_AT, + type: "sign_up.created", + actor: "", + subject: "ins_123", + group_id: GROUP_ID, + group_type: "sign_up", + group_started_at: STARTED_AT, + event_context: { + device: { ip_address: "203.0.113.7", location: { city: "Lima", country_code: "PE" } }, + }, + ...overrides, + }; +} + +function page(data: unknown[], cursor: Record = {}) { + return { status: 200, headers: new Headers(), body: { data, cursor }, rawBody: "" }; +} + +function requestedUrl(call = 0): URL { + const request = mockBapiRequest.mock.calls[call]?.[0] as { path: string }; + return new URL(request.path, "https://api.clerk.test"); +} + +function notFound(): BapiError { + return new BapiError( + 404, + JSON.stringify({ errors: [{ code: "resource_not_found", message: "not found" }] }), + new Headers(), + ); +} + +describe("parseLogLookup", () => { + test.each<[string, "sign_in" | "sign_up"]>([ + ["sia_2xYzAbC123", "sign_in"], + ["sua_2xYzAbC123", "sign_up"], + [" sia_abc ", "sign_in"], + ])("reads %p as a %s group", (raw, groupType) => { + expect(parseLogLookup(raw)).toEqual({ kind: "group", id: raw.trim(), groupType }); + }); + + test("reads a UUID as a log ID", () => { + expect(parseLogLookup("019D8E31-A1BC-7E07-9347-0FD20B25C475")).toEqual({ + kind: "log", + id: "019d8e31-a1bc-7e07-9347-0fd20b25c475", + }); + }); + + test.each([["sia_"], ["sua_"], ["sia_abc/def"], ["user_123"], ["sess_123"], ["hello"], ["1"]])( + "rejects %p as a usage error listing the accepted forms", + (raw) => { + expect(() => parseLogLookup(raw)).toThrow( + `Invalid ID "${raw}". Pass the ID of a log group: a sign-in ID (sia_…) or a sign-up ID (sua_…).`, + ); + }, + ); + + test.each([[undefined], [""], [" "]])("asks for an ID when given %p", (raw) => { + let caught: unknown; + try { + parseLogLookup(raw); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CliError); + expect((caught as CliError).exitCode).toBe(2); + expect((caught as CliError).message).toContain("Pass the ID of a log group"); + }); +}); + +describe("groupSubject", () => { + test("is the latest user a log is about", () => { + expect( + groupSubject([ + makeLog("a", { subject: "ins_1" }), + makeLog("b", { subject: "user_1" }), + makeLog("c", { subject: "user_2" }), + makeLog("d", { subject: "ins_1" }), + ] as never), + ).toBe("user_2"); + }); + + test("falls back to the first log's subject without a user", () => { + expect(groupSubject([makeLog("a", { subject: "ins_1" })] as never)).toBe("ins_1"); + }); + + test("is undefined without logs", () => { + expect(groupSubject([])).toBeUndefined(); + }); +}); + +describe("logs get", () => { + const captured = useCaptureLog(); + let savedSecretKey: string | undefined; + + beforeEach(() => { + savedSecretKey = process.env.CLERK_SECRET_KEY; + delete process.env.CLERK_SECRET_KEY; + mockIsAgent.mockReturnValue(false); + mockResolveBapiSecretKey.mockResolvedValue("sk_test_123"); + mockDescribeBapiTarget.mockResolvedValue("my-app (development)"); + mockBapiRequest.mockResolvedValue(page([makeLog("log_1")], { next_page_status: "false" })); + }); + + afterEach(() => { + if (savedSecretKey === undefined) delete process.env.CLERK_SECRET_KEY; + else process.env.CLERK_SECRET_KEY = savedSecretKey; + mockBapiRequest.mockReset(); + mockResolveBapiSecretKey.mockReset(); + mockDescribeBapiTarget.mockReset(); + mockWithSpinner.mockClear(); + mockIsAgent.mockReset(); + }); + + test("requests the group's events with the pinned API version, the default limit and no started_at", async () => { + await get(GROUP_ID, { json: true }); + + expect(mockBapiRequest).toHaveBeenCalledTimes(1); + expect(mockBapiRequest).toHaveBeenCalledWith({ + method: "GET", + path: `/logs/groups/${GROUP_ID}/events?limit=50`, + secretKey: "sk_test_123", + apiVersion: "2026-08-20", + }); + expect(requestedUrl().searchParams.has("started_at")).toBe(false); + }); + + test("forwards targeting options when resolving the secret key", async () => { + await get(GROUP_ID, { json: true, secretKey: "sk_test_x", app: "app_1", instance: "prod" }); + + expect(mockResolveBapiSecretKey).toHaveBeenCalledWith({ + secretKey: "sk_test_x", + app: "app_1", + instance: "prod", + }); + }); + + test("wraps the request in a spinner", async () => { + await get(GROUP_ID); + + expect(mockWithSpinner).toHaveBeenCalledWith("Fetching logs...", expect.any(Function)); + }); + + test("passes --limit and --cursor (as starting_after) through", async () => { + await get(GROUP_ID, { json: true, limit: 10, cursor: "cur_events" }); + + const url = requestedUrl(); + expect(url.pathname).toBe(`/logs/groups/${GROUP_ID}/events`); + expect(url.searchParams.get("limit")).toBe("10"); + expect(url.searchParams.get("starting_after")).toBe("cur_events"); + }); + + test("sends the event filters", async () => { + await get(GROUP_ID, { + json: true, + type: "sign_up.*", + user: "user_123", + payloadFilter: ["user_id=user_123"], + fields: "user_id", + endUserOnly: true, + }); + + const params = requestedUrl().searchParams; + expect(params.get("type")).toBe("sign_up.*"); + expect(params.get("user_id")).toBe("user_123"); + expect(params.get("payload_filter[user_id]")).toBe("user_123"); + expect(params.get("payload_fields")).toBe("user_id"); + expect(params.get("end_user_facing_only")).toBe("true"); + expect(params.has("view")).toBe(false); + }); + + test("rejects invalid filters before resolving a key or calling the API", async () => { + await expect(get(GROUP_ID, { user: "user_1", actor: "user_2" })).rejects.toThrow( + "--user can't be combined with --actor.", + ); + expect(mockResolveBapiSecretKey).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("rejects a log ID as not supported yet, before any request", async () => { + let caught: unknown; + try { + await get("019d8e31-a1bc-7e07-9347-0fd20b25c475", { json: true }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CliError); + expect((caught as CliError).exitCode).toBe(2); + expect((caught as CliError).message).toBe( + "Looking up a single log by its ID isn't supported yet. Pass the ID of a log group instead: a sign-in ID (sia_…) or a sign-up ID (sua_…).", + ); + expect(mockResolveBapiSecretKey).not.toHaveBeenCalled(); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("rejects an invalid ID before any request", async () => { + await expect(get("user_123", { json: true })).rejects.toThrow('Invalid ID "user_123"'); + expect(mockBapiRequest).not.toHaveBeenCalled(); + }); + + test("turns a 404 into a log-group-not-found error naming the targeted instance", async () => { + mockBapiRequest.mockRejectedValueOnce(notFound()); + + let caught: unknown; + try { + await get(GROUP_ID, { json: true, app: "app_1", instance: "dev" }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(CliError); + expect((caught as CliError).code).toBe("log_group_not_found"); + expect((caught as CliError).message).toStartWith( + `Log group not found: no log group ${GROUP_ID} in my-app (development).`, + ); + expect(mockDescribeBapiTarget).toHaveBeenCalledWith({ + json: true, + app: "app_1", + instance: "dev", + }); + }); + + test("names the secret key's instance on a 404 when --secret-key picked it", async () => { + mockBapiRequest.mockRejectedValueOnce(notFound()); + + await expect(get(GROUP_ID, { json: true, secretKey: "sk_test_x" })).rejects.toThrow( + `Log group not found: no log group ${GROUP_ID} in the instance of this secret key.`, + ); + expect(mockDescribeBapiTarget).not.toHaveBeenCalled(); + }); + + test("names the secret key's instance on a 404 when CLERK_SECRET_KEY picked it", async () => { + process.env.CLERK_SECRET_KEY = "sk_test_env"; + mockBapiRequest.mockRejectedValueOnce(notFound()); + + await expect(get(GROUP_ID, { json: true })).rejects.toThrow( + "in the instance of this secret key.", + ); + expect(mockDescribeBapiTarget).not.toHaveBeenCalled(); + }); + + test("passes other API errors through", async () => { + const error = new BapiError( + 422, + JSON.stringify({ errors: [{ message: "bad" }] }), + new Headers(), + ); + mockBapiRequest.mockRejectedValueOnce(error); + + await expect(get(GROUP_ID, { json: true })).rejects.toBe(error); + }); + + test("rejects a body without a data array", async () => { + mockBapiRequest.mockResolvedValueOnce({ + status: 200, + headers: new Headers(), + body: { cursor: {} }, + rawBody: "", + }); + + await expect(get(GROUP_ID, { json: true })).rejects.toThrow( + "The Logs API returned an unexpected response.", + ); + }); + + test("prints a { data, cursor } envelope with a null next on the last page", async () => { + await get(GROUP_ID, { json: true }); + + expect(JSON.parse(captured.out)).toEqual({ + data: [makeLog("log_1")], + cursor: { next: null }, + }); + }); + + test("prints the next cursor when the group has more logs", async () => { + mockBapiRequest.mockResolvedValueOnce( + page([makeLog("log_1")], { next_page_status: "true", starting_after: "cur_next" }), + ); + + await get(GROUP_ID, { json: true, limit: 1 }); + + expect(JSON.parse(captured.out).cursor).toEqual({ next: "cur_next" }); + }); + + test("makes one request even when the group has more logs", async () => { + mockBapiRequest.mockResolvedValueOnce( + page([makeLog("log_1")], { next_page_status: "true", starting_after: "cur_next" }), + ); + + await get(GROUP_ID, { json: true, limit: 1 }); + + expect(mockBapiRequest).toHaveBeenCalledTimes(1); + }); + + test("outputs JSON in agent mode", async () => { + mockIsAgent.mockReturnValue(true); + + await get(GROUP_ID); + + expect(JSON.parse(captured.out).data).toHaveLength(1); + }); + + test("prints a header, the event table and the summary in human mode", async () => { + mockBapiRequest.mockResolvedValueOnce( + page( + [ + makeLog("log_1"), + makeLog("log_2", { + type: "sign_up.completed", + subject: "user_123", + event_time: STARTED_AT + 1_000, + }), + ], + { next_page_status: "true", starting_after: "cur_next" }, + ), + ); + + await get(GROUP_ID); + + expect(captured.out).toBe(""); + const err = captured.err.replace(ANSI_ESCAPE_PATTERN, ""); + const lines = err.split("\n"); + expect(lines).toContain(`sign_up ${GROUP_ID}`); + expect(err).toContain("Subject user_123"); + expect(err).toMatch(/Started (\d{4}-\d{2}-\d{2} )?\d{2}:\d{2}:\d{2}/); + expect(err).toMatch(/TIME\s+TYPE\s+ACTOR\s+SUBJECT\s+LOCATION\s+LOG ID/); + expect(err).toMatch(/sign_up\.created\s+-\s+ins_123\s+Lima, PE\s+log_1/); + expect(err).toContain("sign_up.completed"); + expect(err).toContain("2 logs · more: re-run with `--cursor cur_next`"); + }); + + test("adds a PAYLOAD column with the requested fields when --fields is set", async () => { + mockBapiRequest.mockResolvedValueOnce( + page([makeLog("log_1", { payload: { sign_up_id: GROUP_ID } })], { + next_page_status: "false", + }), + ); + + await get(GROUP_ID, { type: "sign_up.created", fields: "sign_up_id" }); + + const err = captured.err.replace(ANSI_ESCAPE_PATTERN, ""); + expect(err).toMatch(/LOCATION\s+PAYLOAD\s+LOG ID/); + expect(err).toContain(`sign_up_id=${GROUP_ID}`); + }); + + test("takes the group type from the ID prefix when no log is returned", async () => { + mockBapiRequest.mockResolvedValueOnce(page([], { next_page_status: "false" })); + + await get("sia_abc"); + + const err = captured.err.replace(ANSI_ESCAPE_PATTERN, ""); + expect(err.split("\n")).toContain("sign_in sia_abc"); + expect(err).toContain("No logs found."); + expect(err).toContain("0 logs"); + expect(err).not.toContain("more:"); + }); +}); diff --git a/packages/cli-core/src/commands/logs/get.ts b/packages/cli-core/src/commands/logs/get.ts new file mode 100644 index 000000000..29b36ecdc --- /dev/null +++ b/packages/cli-core/src/commands/logs/get.ts @@ -0,0 +1,259 @@ +/** + * `clerk logs get ` — the logs of one log group (a sign-in or a sign-up + * flow), oldest first, from `GET /v1/logs/groups/{group_id}/events`. + * + * The request sends no `started_at`: the Backend API looks the group's start up + * from its ID, and answers 404 for a group it can't find or one entirely past + * the plan's log retention. Unlike `GET /v1/logs`, the endpoint doesn't scan + * time windows, so a page is always full and one request covers `--limit`. + * + * A single log's ID (a UUID) is recognized but refused for now, because + * `GET /v1/logs/{id}` doesn't take a bare log ID yet (CORE-4354). + */ + +import { describeBapiTarget, resolveBapiSecretKey } from "../../lib/bapi-command.ts"; +import { bold, dim } from "../../lib/color.ts"; +import { + BapiError, + CliError, + ERROR_CODE, + throwUsageError, + withApiContext, +} from "../../lib/errors.ts"; +import { isInsideGutter, log } from "../../lib/log.ts"; +import { closeStatusForError } from "../../lib/signals.ts"; +import { intro, outro, pausedOutro, withSpinner } from "../../lib/spinner.ts"; +import { isAgent } from "../../mode.ts"; +import { resolveFilters, type LogFilterOptions, type LogFilters } from "./filters.ts"; +import { printTable, readLogsPage } from "./list.ts"; +import { formatLogTime, logsRequest, type Log, type LogsTargetingOptions } from "./shared.ts"; + +export type LogsGetOptions = LogsTargetingOptions & + LogFilterOptions & { + limit?: number; + cursor?: string; + }; + +/** What `--json` prints: the group's logs and where to continue from. */ +export type LogsGetResult = { + data: Log[]; + cursor: { + /** Pass to `--cursor` for the group's next logs. Null when there are no more. */ + next: string | null; + }; +}; + +export const DEFAULT_LIMIT = 50; +/** The endpoint's per-request maximum (`pagination.MaxLimit` in clerk_go). */ +export const MAX_LIMIT = 500; + +/** Log group types, keyed by the prefix of the group's ID (the flow's own ID). */ +const GROUP_ID_PREFIXES = { + sia_: "sign_in", + sua_: "sign_up", +} as const; + +export type LogGroupType = (typeof GROUP_ID_PREFIXES)[keyof typeof GROUP_ID_PREFIXES]; + +/** + * What an ID passed to `clerk logs get` names. Only `group` is served today; + * `log` is recognized so the usage error can say why it isn't, and so the + * single-log lookup can be added here once the API accepts a bare log ID. + */ +export type LogLookup = + | { kind: "group"; id: string; groupType: LogGroupType } + | { kind: "log"; id: string }; + +const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +/** A KSUID-style Clerk ID body: base62, at least one character. */ +const ID_BODY_PATTERN = /^[0-9A-Za-z]+$/; + +const GET_EXAMPLES = [ + { command: "clerk logs get sia_123", description: "The logs of a sign-in" }, + { command: "clerk logs get sua_123", description: "The logs of a sign-up" }, +]; + +const ACCEPTED_FORMS = "a sign-in ID (sia_…) or a sign-up ID (sua_…)"; + +/** Classifies an ID, or throws a usage error when it is neither form. */ +export function parseLogLookup(raw: string | undefined): LogLookup { + const id = raw?.trim(); + if (!id) { + throwUsageError( + `Pass the ID of a log group: ${ACCEPTED_FORMS}.`, + undefined, + undefined, + GET_EXAMPLES, + ); + } + for (const [prefix, groupType] of Object.entries(GROUP_ID_PREFIXES)) { + if (id.startsWith(prefix) && ID_BODY_PATTERN.test(id.slice(prefix.length))) { + return { kind: "group", id, groupType }; + } + } + if (UUID_PATTERN.test(id)) return { kind: "log", id: id.toLowerCase() }; + throwUsageError( + `Invalid ID "${id}". Pass the ID of a log group: ${ACCEPTED_FORMS}.`, + undefined, + undefined, + GET_EXAMPLES, + ); +} + +export function buildGroupEventsPath( + groupId: string, + limit: number, + startingAfter: string | undefined, + filters: LogFilters, +): string { + const params = new URLSearchParams({ limit: String(limit) }); + for (const [name, value] of filters) params.append(name, value); + if (startingAfter) params.set("starting_after", startingAfter); + return `/logs/groups/${encodeURIComponent(groupId)}/events?${params}`; +} + +/** + * Where the request went, for the not-found message. A secret key from + * `--secret-key` or `CLERK_SECRET_KEY` (without `--app`) wins in + * {@link resolveBapiSecretKey}, so there is no app or instance to name then. + */ +async function describeTarget(options: LogsGetOptions): Promise { + const keyOnly = "the instance of this secret key"; + if (options.secretKey || (!options.app && process.env.CLERK_SECRET_KEY)) return keyOnly; + try { + return (await describeBapiTarget(options)) ?? keyOnly; + } catch { + return keyOnly; + } +} + +async function groupNotFound(groupId: string, options: LogsGetOptions): Promise { + const target = await describeTarget(options); + return new CliError( + `Log group not found: no log group ${groupId} in ${target}. ` + + "Check the ID and the targeted instance (--app, --instance); a group older than your plan's log retention isn't found either.", + { code: ERROR_CODE.LOG_GROUP_NOT_FOUND }, + ); +} + +async function fetchGroupLogs( + secretKey: string, + groupId: string, + limit: number, + cursor: string | undefined, + filters: LogFilters, +): Promise { + const response = await withApiContext( + logsRequest(buildGroupEventsPath(groupId, limit, cursor, filters), secretKey), + "Failed to read the log group", + ); + const page = readLogsPage(response); + const next = + page.cursor?.next_page_status === "false" ? null : (page.cursor?.starting_after ?? null); + return { data: page.data, cursor: { next } }; +} + +type GroupFields = { group_type?: string | null; group_started_at?: number | null }; + +/** + * Who the flow is about: the latest user any of its logs is about, or the + * first log's subject (usually the instance) when it never identified one. + * The same rule the grouped list's `subject` follows, over the logs fetched. + */ +export function groupSubject(logs: Log[]): string | undefined { + const user = logs.findLast((entry) => entry.subject?.startsWith("user_"))?.subject; + return user || logs[0]?.subject || undefined; +} + +function printHuman( + lookup: Extract, + result: LogsGetResult, + showPayload: boolean, +): void { + const fields = result.data[0] as (Log & GroupFields) | undefined; + const groupType = fields?.group_type || lookup.groupType; + const details: string[] = []; + if (typeof fields?.group_started_at === "number") { + details.push(`Started ${formatLogTime(fields.group_started_at)}`); + } + const subject = groupSubject(result.data); + if (subject) details.push(`Subject ${subject}`); + + log.blank(); + log.info(`${bold(groupType)} ${lookup.id}`); + if (details.length) log.info(dim(details.join(" · "))); + log.blank(); + + if (result.data.length === 0) { + log.warn("No logs found."); + } else { + printTable(result.data, showPayload); + } + const count = `${result.data.length} ${result.data.length === 1 ? "log" : "logs"}`; + const parts = [count]; + if (result.cursor.next) parts.push(`more: re-run with \`--cursor ${result.cursor.next}\``); + log.info(dim(`\n${parts.join(" · ")}`)); +} + +export async function get(id: string | undefined, options: LogsGetOptions = {}): Promise { + const json = Boolean(options.json) || isAgent(); + const shouldWrap = !isInsideGutter() && !json; + if (shouldWrap) intro("Reading log group"); + let closeStatus: "success" | "failed" | "paused" | undefined; + + try { + const lookup = parseLogLookup(id); + if (lookup.kind === "log") { + throwUsageError( + `Looking up a single log by its ID isn't supported yet. Pass the ID of a log group instead: ${ACCEPTED_FORMS}.`, + undefined, + undefined, + GET_EXAMPLES, + ); + } + const filters = resolveFilters(options); + const secretKey = await resolveBapiSecretKey({ + secretKey: options.secretKey, + app: options.app, + instance: options.instance, + }); + + let result: LogsGetResult; + try { + result = await withSpinner("Fetching logs...", async () => + fetchGroupLogs( + secretKey, + lookup.id, + options.limit ?? DEFAULT_LIMIT, + options.cursor, + filters, + ), + ); + } catch (error) { + if (error instanceof BapiError && error.status === 404) { + throw await groupNotFound(lookup.id, options); + } + throw error; + } + + if (json) { + log.data(JSON.stringify(result, null, 2)); + } else { + printHuman(lookup, result, options.fields !== undefined); + } + closeStatus = "success"; + } catch (error) { + closeStatus = closeStatusForError(error); + throw error; + } finally { + if (shouldWrap) { + if (closeStatus === "paused") { + pausedOutro(); + } else if (closeStatus === "failed") { + await outro("Failed"); + } else if (closeStatus === "success") { + await outro(); + } + } + } +} diff --git a/packages/cli-core/src/commands/logs/index.ts b/packages/cli-core/src/commands/logs/index.ts index 4f8e28871..1fb2e0d82 100644 --- a/packages/cli-core/src/commands/logs/index.ts +++ b/packages/cli-core/src/commands/logs/index.ts @@ -4,11 +4,12 @@ import { isExperimentEnabled, requireExperiment } from "../../lib/experimental.t import { collectOptionValues, parseIntegerOption } from "../../lib/option-parsers.ts"; import { FILTER_MATCH_CHOICES } from "./filters.ts"; import { follow } from "./follow.ts"; +import { get, MAX_LIMIT as GET_MAX_LIMIT } from "./get.ts"; import { list } from "./list.ts"; import { schema } from "./schema.ts"; import { parseDuration, parseTimestamp } from "./time.ts"; -const logs = { list, follow, schema }; +const logs = { list, follow, get, schema }; export function registerLogs(program: Program, env: NodeJS.ProcessEnv = process.env): void { // Experimental until `clerk logs` is ready to ship (Linear project @@ -41,6 +42,7 @@ export function registerLogs(program: Program, env: NodeJS.ProcessEnv = process. { command: "clerk logs", description: "List the newest logs" }, { command: "clerk logs --since 30m", description: "Logs from the last 30 minutes" }, { command: "clerk logs --follow", description: "Print new logs as they arrive" }, + { command: "clerk logs get sia_123", description: "The logs of one sign-in" }, { command: "clerk logs --instance prod --json", description: "Production logs as JSON" }, ]); @@ -145,6 +147,67 @@ export function registerLogs(program: Program, env: NodeJS.ProcessEnv = process. return options.follow ? logs.follow(options) : logs.list(options); }); + // The event filters of `list`; get has no time range, `--follow` or group + // filters, since the group is already chosen. + logsCommand + .command("get") + .description("Show the logs of a log group (a sign-in or sign-up), oldest first") + .argument("[id]", "A log group ID: a sign-in ID (sia_...) or a sign-up ID (sua_...)") + .option("--json", "Output as JSON") + .option( + "--limit ", + `Maximum logs to return (1-${GET_MAX_LIMIT}, default 50)`, + (value) => parseIntegerOption(value, "--limit", { min: 1, max: GET_MAX_LIMIT }), + ) + .option("--cursor ", "Continue from the cursor a previous run printed") + .option("--type ", "Only logs of a type; a trailing * matches a prefix (sign_in.*)") + .option("--user ", "Only logs where the user is the actor or subject") + .option( + "--identifier ", + "Only logs for an email, phone number, username or web3 wallet (exact)", + ) + .option("--actor ", "Only logs with this actor") + .option("--subject ", "Only logs with this subject") + .option("--session ", "Only logs from requests made with this session") + .option("--client ", "Only logs of a client") + .option("--ip
", "Only logs from an IP address") + .option("--trace ", "Only logs of a trace") + .option("--impersonator ", "Only logs made while this user impersonated someone") + .option( + "--payload-filter ", + "Only logs whose payload field equals a value (repeatable; needs --type)", + collectOptionValues, + ) + .option("--fields ", "Payload fields to include, comma-separated (needs --type)") + .addOption( + createOption("--match ", "Match all filters (default) or any of them").choices( + FILTER_MATCH_CHOICES, + ), + ) + .option("--end-user-only", "Only event types shown to end users") + .option("--secret-key ", "Backend API secret key to use") + .option("--app ", "Application ID to target (works from any directory)") + .option("--instance ", "Instance to target (dev, prod, or a full instance ID)") + .setExamples([ + { command: "clerk logs get sia_123", description: "The logs of a sign-in" }, + { command: "clerk logs get sua_123 --json", description: "The logs of a sign-up as JSON" }, + { + command: "clerk logs get sia_123 --type 'sign_in.*'", + description: "Only the sign_in logs of a sign-in", + }, + { + command: "clerk logs get sia_123 --cursor ", + description: "Continue where a previous run stopped", + }, + { + command: "clerk logs get sia_123 --app app_123 --instance prod", + description: "A sign-in of another application's production instance", + }, + ]) + .action(async (id: string | undefined, _opts, cmd) => + logs.get(id, cmd.optsWithGlobals() as Parameters[1]), + ); + logsCommand .command("schema") .description("Show the payload fields of a log event type") diff --git a/packages/cli-core/src/commands/logs/list.ts b/packages/cli-core/src/commands/logs/list.ts index 3e717371b..9f1f5ed3a 100644 --- a/packages/cli-core/src/commands/logs/list.ts +++ b/packages/cli-core/src/commands/logs/list.ts @@ -211,7 +211,8 @@ export function logColumnHeaders(showPayload: boolean): string[] { return headers; } -function printTable(logs: Log[], showPayload: boolean): void { +/** The `list` table: one row per log, columns sized to the widest cell. */ +export function printTable(logs: Log[], showPayload: boolean): void { const now = new Date(); const headers = logColumnHeaders(showPayload); const rows = [ diff --git a/packages/cli-core/src/lib/errors.ts b/packages/cli-core/src/lib/errors.ts index c9a0d58da..9b8f29fa5 100644 --- a/packages/cli-core/src/lib/errors.ts +++ b/packages/cli-core/src/lib/errors.ts @@ -136,6 +136,8 @@ export const ERROR_CODE = { DEPLOY_INSTANCE_UNRESOLVED: "deploy_instance_unresolved", /** Local publishable key and secret key address different applications. */ KEY_PAIR_MISMATCH: "key_pair_mismatch", + /** `clerk logs get` found no log group with that ID in the targeted instance. */ + LOG_GROUP_NOT_FOUND: "log_group_not_found", /** BAPI returned a response the CLI could not use. */ BAPI_UNEXPECTED_RESPONSE: "bapi_unexpected_response", /** The command needs a TTY that isn't available. */ diff --git a/test/e2e/lib/fapi-sign-up.ts b/test/e2e/lib/fapi-sign-up.ts new file mode 100644 index 000000000..a4a4a8e9a --- /dev/null +++ b/test/e2e/lib/fapi-sign-up.ts @@ -0,0 +1,114 @@ +/** + * Signs a new `+clerk_test` user up through the Frontend API of the test + * application's development instance, as a browser would. Creating a user + * through the Backend API runs no sign-up flow, so this is how an E2E test gets + * a sign-up log group: the sign-up's ID is the group's ID. + */ + +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { log } from "./logger.ts"; + +const CLI_PATH = join(import.meta.dir, "../../../packages/cli-core/src/cli.ts"); +/** The code FAPI accepts for a `+clerk_test` email address. */ +const TEST_CODE = "424242"; + +export type FapiSignUp = { + id: string; + status: string; + unverified_fields?: string[]; + created_user_id?: string | null; +}; + +async function cliJson(configDir: string, args: string[]): Promise { + const result = await Bun.$`bun ${CLI_PATH} ${args}` + .env({ ...process.env, CLERK_CONFIG_DIR: configDir, CLERK_TELEMETRY_DISABLED: "1" }) + .nothrow() + .quiet(); + if (result.exitCode !== 0) { + throw new Error( + `\`clerk ${args.join(" ")}\` exited ${result.exitCode}: ${result.stderr.toString().trim()}`, + ); + } + return JSON.parse(result.stdout.toString()) as T; +} + +/** The development instance's Frontend API host, from its publishable key. */ +async function fapiHost(configDir: string, appId: string): Promise { + const apps = await cliJson< + Array<{ + application_id: string; + instances: Array<{ environment_type: string; publishable_key: string }>; + }> + >(configDir, ["apps", "list", "--json"]); + const instance = apps + .find((app) => app.application_id === appId) + ?.instances.find((candidate) => candidate.environment_type === "development"); + if (!instance) throw new Error(`No development instance found for ${appId}.`); + // pk_test_$">. Decoded here rather than through the CLI's + // decodePublishableKey, whose module graph needs the CLI's build-time globals. + const encoded = instance.publishable_key.replace(/^pk_(test|live)_/, ""); + return atob(encoded).replace(/\$$/, ""); +} + +/** + * Creates a sign-up with an email and password and verifies the email with the + * `+clerk_test` code, using a dev browser token and a testing token so bot + * protection lets it through. `onCreated` gets the sign-up as soon as it + * exists, so a caller keeps its ID even if finishing the sign-up fails. + */ +export async function signUpThroughFapi( + configDir: string, + appId: string, + onCreated?: (signUp: FapiSignUp) => void, +): Promise { + const host = await fapiHost(configDir, appId); + const { token: testingToken } = await cliJson<{ token: string }>(configDir, [ + "api", + "/testing_tokens", + "-X", + "POST", + "--yes", + "--app", + appId, + ]); + + const devBrowser = await fetch(`https://${host}/v1/dev_browser`, { method: "POST" }); + if (!devBrowser.ok) throw new Error(`POST /v1/dev_browser: ${devBrowser.status}`); + const { token: devBrowserToken } = (await devBrowser.json()) as { token: string }; + + const post = async (path: string, form: Record): Promise => { + const url = new URL(`https://${host}/v1${path}`); + url.searchParams.set("__clerk_db_jwt", devBrowserToken); + url.searchParams.set("__clerk_testing_token", testingToken); + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(form), + }); + const text = await response.text(); + if (!response.ok) throw new Error(`POST ${path}: ${response.status} ${text.slice(0, 500)}`); + return (JSON.parse(text) as { response: FapiSignUp }).response; + }; + + const hex = randomBytes(8).toString("hex"); + let signUp = await post("/client/sign_ups", { + email_address: `${hex}+clerk_test@clerkcookie.com`, + password: `Test${hex}!1`, + }); + onCreated?.(signUp); + log(`sign-up created: ${signUp.id} (${signUp.status})`); + + if ( + signUp.status === "missing_requirements" && + signUp.unverified_fields?.includes("email_address") + ) { + await post(`/client/sign_ups/${signUp.id}/prepare_verification`, { strategy: "email_code" }); + signUp = await post(`/client/sign_ups/${signUp.id}/attempt_verification`, { + strategy: "email_code", + code: TEST_CODE, + }); + log(`sign-up verified: ${signUp.id} (${signUp.status})`); + } + return signUp; +} diff --git a/test/e2e/logs-get.test.ts b/test/e2e/logs-get.test.ts new file mode 100644 index 000000000..cabc7d21a --- /dev/null +++ b/test/e2e/logs-get.test.ts @@ -0,0 +1,146 @@ +/** + * Live-BAPI roundtrip test for `clerk logs get `. Pins the + * `{ data, cursor }` envelope the CLI prints on top of + * `GET /v1/logs/groups/{group_id}/events`, sent without `started_at`, against + * the real Clerk Backend API. + * + * Signs a user up through the Frontend API of the test application's + * development instance, so a sign-up log group exists, then reads the group by + * the sign-up's ID. + * + * Requires `CLERK_PLATFORM_API_KEY` and `CLERK_CLI_TEST_APP_ID`. Locally, + * run via `bun run test:e2e:op` so 1Password resolves both in-memory. + */ + +import { test, expect, afterAll, beforeAll } from "bun:test"; +import { join } from "node:path"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { signUpThroughFapi } from "./lib/fapi-sign-up.ts"; +import { deleteTestUser } from "./lib/test-user.ts"; + +const CLI_PATH = join(import.meta.dir, "../../packages/cli-core/src/cli.ts"); + +let APP_ID: string; +let configDir: string; +let signUpId: string | undefined; +let userId: string | undefined; +// The first `logs get` output that includes the sign-up's `sign_up.created` log. +let fetched: GetOutput | undefined; + +type GetOutput = { + data: Array<{ + object: unknown; + id: unknown; + event_time: unknown; + type: unknown; + group_id?: unknown; + group_type?: unknown; + }>; + cursor: { next: unknown }; +}; + +function logsGet(args: string[]) { + return Bun.$`bun ${CLI_PATH} logs get ${args} --app ${APP_ID}` + .env({ + ...process.env, + CLERK_CONFIG_DIR: configDir, + CLERK_TELEMETRY_DISABLED: "1", + CLERK_EXPERIMENTAL: "logs", + }) + .nothrow() + .quiet(); +} + +// Shared setup, so each test also passes when run alone with `-t`. +beforeAll(async () => { + const appId = process.env.CLERK_CLI_TEST_APP_ID; + const platformKey = process.env.CLERK_PLATFORM_API_KEY; + if (!appId || !platformKey) { + throw new Error( + "CLERK_CLI_TEST_APP_ID and CLERK_PLATFORM_API_KEY are required. " + + "Run via `bun run test:e2e:op` for local 1Password injection.", + ); + } + APP_ID = appId; + configDir = mkdtempSync(join(tmpdir(), "clerk-cli-e2e-logs-get-")); + + const signUp = await signUpThroughFapi(configDir, APP_ID, (created) => { + signUpId = created.id; + }); + userId = signUp.created_user_id ?? undefined; + + // Logs reach ClickHouse through the event stream pipeline, so the group is + // a 404 until its first log lands. Up to ~60s; a failed run (the 404, a + // transient API error or a rate limit) is retried the same way. + const attempts = 20; + let lastAttempt = ""; + for (let attempt = 1; attempt <= attempts; attempt++) { + const result = await logsGet([signUpId!, "--json"]); + if (result.exitCode === 0) { + const output = JSON.parse(result.stdout.toString()) as GetOutput; + if (output.data.some((entry) => entry.type === "sign_up.created")) { + fetched = output; + return; + } + lastAttempt = `exit 0, ${output.data.length} logs, none of them sign_up.created`; + } else { + lastAttempt = `exit ${result.exitCode}: ${result.stderr.toString().trim().slice(0, 500)}`; + } + if (attempt < attempts) await Bun.sleep(3_000); + } + throw new Error( + `No sign_up.created log in group ${signUpId} after ${attempts} \`logs get\` attempts. Last attempt: ${lastAttempt}`, + ); +}, 120_000); + +afterAll(async () => { + if (userId) await deleteTestUser(userId, configDir, { appId: APP_ID }); + rmSync(configDir, { recursive: true, force: true }); +}, 60_000); + +test("logs get --json returns a { data, cursor } envelope of the group's logs", () => { + expect(fetched).toBeDefined(); + const output = fetched!; + expect(Array.isArray(output.data)).toBe(true); + expect(output.data.length).toBeGreaterThan(0); + expect( + output.data.every( + (entry) => + entry.object === "log" && + typeof entry.id === "string" && + typeof entry.event_time === "number" && + typeof entry.type === "string" && + entry.group_id === signUpId && + entry.group_type === "sign_up", + ), + ).toBe(true); + + // A group's logs come oldest first. + const times = output.data.map((entry) => entry.event_time as number); + expect(times).toEqual([...times].sort((a, b) => a - b)); + + expect(output.cursor.next === null || typeof output.cursor.next === "string").toBe(true); +}); + +test("logs get --limit 1 returns one log and a cursor that continues the group", async () => { + const first = await logsGet([signUpId!, "--json", "--limit", "1"]); + expect(first.exitCode).toBe(0); + const page = JSON.parse(first.stdout.toString()) as GetOutput; + expect(page.data).toHaveLength(1); + if (fetched!.data.length < 2) return; + + expect(typeof page.cursor.next).toBe("string"); + const rest = await logsGet([signUpId!, "--json", "--cursor", page.cursor.next as string]); + expect(rest.exitCode).toBe(0); + const next = JSON.parse(rest.stdout.toString()) as GetOutput; + expect(next.data.length).toBeGreaterThan(0); + expect(next.data.every((entry) => entry.group_id === signUpId)).toBe(true); + expect(next.data.map((entry) => entry.id)).not.toContain(page.data[0]!.id); +}, 60_000); + +test("logs get with an unknown sign-up ID fails with log_group_not_found", async () => { + const result = await logsGet(["sua_00000000000000000000000000", "--json"]); + expect(result.exitCode).toBe(1); + expect(result.stderr.toString()).toContain("log_group_not_found"); +}, 60_000);