Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1978,6 +1978,35 @@ blocked, stopping discards queued output instead of waiting for the reader.
Use `event stream` to consume events in a script. Use `listen` to forward exact
bodies to a local webhook handler.

### Request logs

`polymorfa logs tail` prints a project's API requests as they happen, like
`stripe logs tail`. It needs a team key or project token with `logs:read`.

```shell
polymorfa logs tail \
--project project_123 \
--status 4xx,5xx \
--method POST
```

Each line shows the time, method, route pattern, status, duration, error code,
source, request ID and W3C trace ID. The command prints the 10 most recent
matching requests (`--backfill 0` to 100), then polls for new ones every
`--poll-interval` seconds (default 2, 1 to 60). It reads again at once while
more requests are waiting, and waits for `Retry-After` when the read limit of
120 a minute is reached. `--json` writes one JSON line per request.
`--max-events` stops after a fixed number. Ctrl-C stops with exit code 0.

Filters: `--status` (codes or classes), `--method`, `--route` (exact pattern
such as `/messaging/:session/messages`), `--source api|mcp`, `--credential`
(key or token ID), `--request-id` and `--trace-id`. Send a `traceparent` header
with your API requests to find them by your own trace ID.

`polymorfa logs list` prints one page, newest first, with the same filters plus
`--since`, `--until` and `--limit`. Pass the printed `--cursor` for older
requests. Requests older than 30 days are not listed.

## Local event listening and forwarding

`polymorfa listen` opens the project listener stream and can forward the exact
Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,9 @@
"label:chat": {
"description": "Manage the complete label set assigned to a chat."
},
"logs": {
"description": "Read and follow a project's API request log."
},
"media": {
"description": "Inspect, download, and persist Messaging media."
},
Expand Down
84 changes: 84 additions & 0 deletions src/commands/logs/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {Flags} from '@oclif/core'

import {BaseCommand} from '../../base-command.js'
import {developerRead} from '../../developer/output.js'
import {CliConditionError} from '../../errors/conditions.js'
import {listRequestLogs, requestLogLine} from '../../services/request-logs.js'
import {
requestLogFilterFlags,
requestLogFilters,
} from '../../input/request-log-filters.js'

export default class LogsList extends BaseCommand {
static override description =
"List a project's API requests from the last 30 days, newest first. Requires logs:read."

static override examples = [
'<%= config.bin %> logs list --project 11111111-1111-4111-8111-111111111111 --status 4xx,5xx',
'<%= config.bin %> logs list --project 11111111-1111-4111-8111-111111111111 --trace-id 4bf92f3577b34da6a3ce929d0e0e4736 --json',
]

static override flags = {
...requestLogFilterFlags,
cursor: Flags.string({
description:
'Continue from page.nextCursor of an earlier page; keeps its filters.',
}),
limit: Flags.integer({
description: 'Requests per page (default 25).',
max: 100,
min: 1,
}),
since: Flags.string({description: 'Earliest request time (RFC 3339).'}),
until: Flags.string({description: 'Latest request time (RFC 3339).'}),
}

async run(): Promise<void> {
const {flags} = await this.parse(LogsList)
const session = await this.developerSession({
...flags,
owner: flags.owner ?? 'project',
})
const projectId = session.context.projectId
if (session.context.owner !== 'project' || !projectId) {
throw new CliConditionError(
'action_required',
'Request logs belong to a project. Pass --project or use a project token.',
)
}
const filters = requestLogFilters(flags)
if (
flags.cursor !== undefined &&
(Object.keys(filters).length > 0 || flags.since || flags.until)
) {
throw new CliConditionError(
'action_required',
'--cursor keeps the filters of the page that returned it; do not combine it with filters.',
)
}
const page = await listRequestLogs(session.client, projectId, {
...filters,
...(flags.since === undefined ? {} : {since: flags.since}),
...(flags.until === undefined ? {} : {until: flags.until}),
...(flags.limit === undefined ? {} : {limit: flags.limit}),
...(flags.cursor === undefined ? {} : {cursor: flags.cursor}),
})
const human = [
...page.items.map((log) => requestLogLine(log)),
...(page.nextCursor === null
? []
: [`Older requests: --cursor ${page.nextCursor}`]),
].join('\n')
const outputPage = {
nextCursor: page.nextCursor,
hasMore: page.hasMore,
// Pass as --after to the API, or let `logs tail` follow for you.
followCursor: page.followCursor,
}
this.writeValue(
developerRead(page.items, page.metadata, session.context, outputPage),
page.items.length === 0 ? 'No requests.\n' : `${human}\n`,
flags,
)
}
}
137 changes: 137 additions & 0 deletions src/commands/logs/tail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import {once} from 'node:events'
import {Flags} from '@oclif/core'

import {BaseCommand} from '../../base-command.js'
import {CliConditionError} from '../../errors/conditions.js'
import {
POLL_INTERVAL_SECONDS,
requestLogLine,
tailRequestLogs,
type RequestLog,
} from '../../services/request-logs.js'
import {
requestLogFilterFlags,
requestLogFilters,
} from '../../input/request-log-filters.js'

export default class LogsTail extends BaseCommand {
static override description =
"Print a project's API requests as they happen. Polls the request log and requires logs:read."

static override examples = [
'<%= config.bin %> logs tail --project 11111111-1111-4111-8111-111111111111',
'<%= config.bin %> logs tail --project 11111111-1111-4111-8111-111111111111 --status 4xx,5xx --method POST',
'<%= config.bin %> logs tail --project 11111111-1111-4111-8111-111111111111 --poll-interval 5 --json',
]

static override flags = {
...requestLogFilterFlags,
backfill: Flags.integer({
default: 10,
description: 'Print this many recent requests before following.',
max: 100,
min: 0,
}),
'max-events': Flags.integer({
description: 'Stop after printing this many requests.',
min: 1,
}),
'poll-interval': Flags.integer({
default: POLL_INTERVAL_SECONDS.default,
description: 'Seconds between polls while no new requests arrive.',
max: POLL_INTERVAL_SECONDS.max,
min: POLL_INTERVAL_SECONDS.min,
}),
}

async run(): Promise<void> {
const {flags} = await this.parse(LogsTail)
const session = await this.developerSession({
...flags,
owner: flags.owner ?? 'project',
})
const projectId = session.context.projectId
if (session.context.owner !== 'project' || !projectId) {
throw new CliConditionError(
'action_required',
'Request logs belong to a project. Pass --project or use a project token.',
)
}
const json = flags.json === true
const quiet = flags.quiet === true
const controller = new AbortController()
let interrupted = false
const stop = () => {
interrupted = true
controller.abort()
}
process.once('SIGINT', stop)
process.once('SIGTERM', stop)
const stdoutClosed = (error: unknown) => {
if (isClosedPipe(error)) controller.abort()
else throw error
}
process.stdout.on('error', stdoutClosed)
let printed = 0
try {
if (!json && !quiet) {
process.stderr.write(
`Following request logs for project ${projectId}. Press Ctrl-C to stop.\n`,
)
}
for await (const log of tailRequestLogs(session.client, projectId, {
...requestLogFilters(flags),
backfill: flags.backfill,
intervalMs: flags['poll-interval'] * 1000,
signal: controller.signal,
onRateLimit: (waitMs) => {
if (!json && !quiet) {
process.stderr.write(
`Read limit reached; waiting ${Math.ceil(waitMs / 1000)} s.\n`,
)
}
},
})) {
printed += 1
if (json)
await writeStdout(
`${JSON.stringify(jsonLine(log))}\n`,
controller.signal,
)
else if (!quiet)
await writeStdout(`${requestLogLine(log)}\n`, controller.signal)
if (controller.signal.aborted) break
if (flags['max-events'] !== undefined && printed >= flags['max-events'])
break
}
} finally {
process.removeListener('SIGINT', stop)
process.removeListener('SIGTERM', stop)
process.stdout.removeListener('error', stdoutClosed)
if (interrupted && process.stdout.writableNeedDrain) process.exit(0)
}
}
}

function jsonLine(log: RequestLog) {
return {schemaVersion: 1, type: 'request', request: log}
}

async function writeStdout(value: string, signal: AbortSignal): Promise<void> {
if (process.stdout.write(value)) return
try {
await once(process.stdout, 'drain', {signal})
} catch (error) {
if (isClosedPipe(error)) return
if (signal.aborted) return
throw error
}
}

function isClosedPipe(error: unknown): boolean {
return (
error instanceof Error &&
'code' in error &&
(error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED')
)
}
5 changes: 4 additions & 1 deletion src/errors/present.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,10 @@ export function presentError(
}

/** `Retry-After` as whole seconds from now, from delta-seconds or an HTTP-date. */
function retryAfter(error: unknown, now = Date.now()): number | undefined {
export function retryAfter(
error: unknown,
now = Date.now(),
): number | undefined {
if (!(error instanceof PolymorfaError)) return undefined
const header = error.metadata?.headers['retry-after']?.trim()
if (header === undefined || header.length === 0) return undefined
Expand Down
66 changes: 66 additions & 0 deletions src/input/request-log-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import {Flags} from '@oclif/core'

import type {RequestLogFilters} from '../services/request-logs.js'

/** Filters shared by `logs list` and `logs tail`. */
export const requestLogFilterFlags = {
credential: Flags.string({
description: 'Only requests made by this team key or project token ID.',
}),
method: Flags.string({
description: 'Only these HTTP methods, for example POST or GET,DELETE.',
multiple: true,
}),
'request-id': Flags.string({
description: 'Only the request with this X-Request-Id.',
}),
route: Flags.string({
description:
'Only this exact route pattern, for example /messaging/:session/messages.',
}),
source: Flags.string({
description: 'Only direct API requests or requests made through MCP.',
options: ['api', 'mcp'],
}),
status: Flags.string({
description:
'Only these status codes or classes, for example 404 or 4xx,5xx.',
multiple: true,
}),
'trace-id': Flags.string({
description: 'Only requests with this W3C trace ID (X-Trace-Id).',
}),
}

export function requestLogFilters(flags: {
readonly credential?: string | undefined
readonly method?: string[] | undefined
readonly 'request-id'?: string | undefined
readonly route?: string | undefined
readonly source?: string | undefined
readonly status?: string[] | undefined
readonly 'trace-id'?: string | undefined
}): RequestLogFilters {
const list = (values: string[] | undefined) =>
values?.flatMap((value) =>
value
.split(',')
.map((item) => item.trim())
.filter(Boolean),
)
const status = list(flags.status)
const method = list(flags.method)?.map((value) => value.toUpperCase())
return {
...(status?.length ? {status} : {}),
...(method?.length ? {method} : {}),
...(flags.route === undefined ? {} : {route: flags.route}),
...(flags.source === undefined ? {} : {source: flags.source}),
...(flags.credential === undefined ? {} : {credentialId: flags.credential}),
...(flags['request-id'] === undefined
? {}
: {requestId: flags['request-id']}),
...(flags['trace-id'] === undefined
? {}
: {traceId: flags['trace-id'].toLowerCase()}),
}
}
Loading