Skip to content
Draft
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
53 changes: 52 additions & 1 deletion src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,53 @@ test('metro reload forwards host, port, bundle URL, and timeout to client.metro.
assert.equal(stdout, 'Reloaded React Native apps via http://127.0.0.1:9090/reload\n');
});

test('events prints a parsed session timeline from the generic CLI route', async () => {
let observed: Parameters<AgentDeviceClient['observability']['events']>[0] | undefined;
const client = createStubClient({
installFromSource: async () => {
throw new Error('unexpected install call');
},
events: async (options) => {
observed = options;
return {
path: '/tmp/session/events.ndjson',
cursor: '0',
limit: 100,
events: [
{
version: 1,
ts: '2026-07-02T12:00:00.000Z',
session: 'default',
kind: 'action.recorded',
command: 'click',
summary: 'Tapped @e14',
details: { ref: '@e14' },
},
],
};
},
});

const output = await captureOutput(async () => {
const handled = await tryRunClientBackedCommand({
command: 'events',
positionals: [],
flags: {
json: false,
help: false,
version: false,
},
client,
});
assert.equal(handled, true);
});

assert.equal(observed?.limit, undefined);
assert.equal(observed?.cursor, undefined);
assert.match(output.stdout, /action click\s+Tapped @e14/);
assert.match(output.stderr, /path=\/tmp\/session\/events\.ndjson/);
});

test('screenshot forwards --overlay-refs to the client capture API', async () => {
let observed:
| {
Expand Down Expand Up @@ -1074,6 +1121,7 @@ async function captureOutput(
function createStubClient(params: {
installFromSource: AgentDeviceClient['apps']['installFromSource'];
listApps?: AgentDeviceClient['apps']['list'];
events?: AgentDeviceClient['observability']['events'];
prepareMetro?: AgentDeviceClient['metro']['prepare'];
reloadMetro?: AgentDeviceClient['metro']['reload'];
open?: AgentDeviceClient['apps']['open'];
Expand Down Expand Up @@ -1197,7 +1245,10 @@ function createStubClient(params: {
interactions: createThrowingMethodGroup<AgentDeviceClient['interactions']>(),
replay: createThrowingMethodGroup<AgentDeviceClient['replay']>(),
batch: createThrowingMethodGroup<AgentDeviceClient['batch']>(),
observability: createThrowingMethodGroup<AgentDeviceClient['observability']>(),
observability: {
...createThrowingMethodGroup<AgentDeviceClient['observability']>(),
events: params.events ?? unexpectedCommandCall,
},
debug: createThrowingMethodGroup<AgentDeviceClient['debug']>(),
recording: createThrowingMethodGroup<AgentDeviceClient['recording']>(),
settings: {
Expand Down
1 change: 1 addition & 0 deletions src/client/client-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ export function serializeOpenResult(result: AppOpenResult): Record<string, unkno
...(result.sessionStateDir ? { sessionStateDir: result.sessionStateDir } : {}),
...(result.runnerLogPath ? { runnerLogPath: result.runnerLogPath } : {}),
...(result.requestLogPath ? { requestLogPath: result.requestLogPath } : {}),
...(result.eventLogPath ? { eventLogPath: result.eventLogPath } : {}),
...(result.appName ? { appName: result.appName } : {}),
...(result.appBundleId ? { appBundleId: result.appBundleId } : {}),
...(result.startup ? { startup: result.startup } : {}),
Expand Down
7 changes: 7 additions & 0 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export type AppOpenResult = {
sessionStateDir?: string;
runnerLogPath?: string;
requestLogPath?: string;
eventLogPath?: string;
appName?: string;
appBundleId?: string;
appId?: string;
Expand Down Expand Up @@ -777,6 +778,11 @@ export type LogsOptions = AgentDeviceRequestOverrides & {
restart?: boolean;
};

export type EventsOptions = AgentDeviceRequestOverrides & {
cursor?: string;
limit?: number;
};

export type NetworkOptions = AgentDeviceRequestOverrides & {
action?: 'dump' | 'log';
limit?: number;
Expand Down Expand Up @@ -1019,6 +1025,7 @@ export type AgentDeviceClient = {
observability: {
perf: (options?: PerfOptions) => Promise<CommandRequestResult>;
logs: (options?: LogsOptions) => Promise<CommandRequestResult>;
events: (options?: EventsOptions) => Promise<CommandRequestResult>;
network: (options?: NetworkOptions) => Promise<CommandRequestResult>;
audio: (options?: AudioOptions) => Promise<CommandRequestResult>;
};
Expand Down
2 changes: 2 additions & 0 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,7 @@ export function createAgentDeviceClient(
return {
session,
sessionStateDir: readOptionalString(data, 'sessionStateDir'),
eventLogPath: readOptionalString(data, 'eventLogPath'),
appName: readOptionalString(data, 'appName'),
appBundleId,
appId,
Expand Down Expand Up @@ -333,6 +334,7 @@ export function createAgentDeviceClient(
observability: {
perf: async (options = {}) => await executeCommand('perf', options),
logs: async (options = {}) => await executeCommand('logs', options),
events: async (options = {}) => await executeCommand('events', options),
network: async (options = {}) => await executeCommand('network', options),
audio: async (options = {}) => await executeCommand('audio', options),
},
Expand Down
2 changes: 2 additions & 0 deletions src/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const PUBLIC_COMMANDS = {
clipboard: 'clipboard',
devices: 'devices',
doctor: 'doctor',
events: 'events',
diff: 'diff',
fill: 'fill',
find: 'find',
Expand Down Expand Up @@ -121,6 +122,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
PUBLIC_COMMANDS.batch,
PUBLIC_COMMANDS.devices,
PUBLIC_COMMANDS.doctor,
PUBLIC_COMMANDS.events,
PUBLIC_COMMANDS.gesture,
PUBLIC_COMMANDS.replay,
PUBLIC_COMMANDS.test,
Expand Down
82 changes: 82 additions & 0 deletions src/commands/observability/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import {
audioCommandDefinition,
audioCommandMetadata,
audioDaemonWriter,
eventsCliReader,
eventsCommandDefinition,
eventsCommandMetadata,
eventsDaemonWriter,
logsCliReader,
logsCommandDefinition,
logsCommandMetadata,
Expand All @@ -14,6 +18,7 @@ import {
networkCommandMetadata,
networkDaemonWriter,
} from './index.ts';
import { observabilityCliOutputFormatters } from './output.ts';

const NO_FLAGS = {} as CliFlags;

Expand All @@ -30,6 +35,8 @@ describe('observability command interface', () => {
test('owns logs and network public metadata', () => {
expect(audioCommandMetadata.name).toBe('audio');
expect(audioCommandDefinition.name).toBe('audio');
expect(eventsCommandMetadata.name).toBe('events');
expect(eventsCommandDefinition.name).toBe('events');
expect(logsCommandMetadata.name).toBe('logs');
expect(logsCommandDefinition.name).toBe('logs');
expect(networkCommandMetadata.name).toBe('network');
Expand Down Expand Up @@ -68,6 +75,81 @@ describe('observability command interface', () => {
});
});

test('reads events pagination as compact daemon positionals', () => {
expect(eventsCliReader(['25', '100'], NO_FLAGS)).toEqual({
limit: 25,
cursor: '100',
});
expect(eventsCliReader(['', '100'], NO_FLAGS)).toEqual({
limit: undefined,
cursor: '100',
});
expect(eventsDaemonWriter({ limit: 25, cursor: '100' })).toMatchObject({
command: 'events',
positionals: ['25', '100'],
});
expect(eventsDaemonWriter({ cursor: '100' })).toMatchObject({
command: 'events',
positionals: ['', '100'],
});
});

test('formats events as a compact human timeline', () => {
const output = observabilityCliOutputFormatters.events({
input: {},
result: {
path: '/tmp/session/events.ndjson',
cursor: '0',
limit: 100,
events: [
{
version: 1,
ts: '2026-07-02T12:00:00.000Z',
session: 'default',
kind: 'request.started',
command: 'open',
summary: 'Started open',
},
{
version: 1,
ts: '2026-07-02T12:00:00.250Z',
session: 'default',
kind: 'request.finished',
command: 'open',
status: 'ok',
summary: 'Finished open',
details: { durationMs: 250 },
},
{
version: 1,
ts: '2026-07-02T12:00:01.000Z',
session: 'default',
kind: 'action.recorded',
command: 'fill',
summary: 'Filled @e14',
details: { ref: '@e14', textLength: 8 },
},
],
},
});

expect(output.text).toContain('2026-07-02 12:00:00.000Z start open');
expect(output.text).toContain('2026-07-02 12:00:00.250Z ok open 250ms');
expect(output.text).toContain('2026-07-02 12:00:01.000Z action fill');
expect(output.text).toContain('Filled @e14 (text=8 chars)');
expect(output.stderr).toContain('path=/tmp/session/events.ndjson');
});

test('formats empty events page with a readable message', () => {
const output = observabilityCliOutputFormatters.events({
input: {},
result: { path: '/tmp/session/events.ndjson', cursor: '0', limit: 100, events: [] },
});

expect(output.text).toBe('No session events found.');
expect(output.stderr).toContain('cursor=0');
});

test('reads network include from flag or positional', () => {
expect(networkCliReader(['dump', '25', 'headers'], NO_FLAGS)).toEqual({
action: 'dump',
Expand Down
57 changes: 55 additions & 2 deletions src/commands/observability/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AudioOptions, LogsOptions, NetworkOptions } from '../../client/client-types.ts';
import type {
AudioOptions,
EventsOptions,
LogsOptions,
NetworkOptions,
} from '../../client/client-types.ts';
import { NETWORK_INCLUDE_MODES, type NetworkIncludeMode } from '../../kernel/contracts.ts';
import { AppError } from '../../kernel/errors.ts';
import { parseStringMember } from '../../utils/string-enum.ts';
Expand All @@ -20,13 +25,15 @@ import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
import { observabilityCliOutputFormatters } from './output.ts';

const LOGS_COMMAND_NAME = 'logs';
const EVENTS_COMMAND_NAME = 'events';
const NETWORK_COMMAND_NAME = 'network';
const AUDIO_COMMAND_NAME = 'audio';
const NETWORK_ACTION_VALUES = ['dump', 'log'] as const;
const AUDIO_ACTION_VALUES = ['probe'] as const;
const AUDIO_PROBE_ACTION_VALUES = ['start', 'status', 'stop'] as const;

const logsCommandDescription = 'Manage session app logs.';
const eventsCommandDescription = 'Read the session event timeline.';
const networkCommandDescription = 'Show recent HTTP traffic.';
const audioCommandDescription = 'Probe audio levels.';

Expand All @@ -40,6 +47,15 @@ export const logsCommandMetadata = defineFieldCommandMetadata(
},
);

export const eventsCommandMetadata = defineFieldCommandMetadata(
EVENTS_COMMAND_NAME,
eventsCommandDescription,
{
limit: integerField(),
cursor: stringField(),
},
);

export const networkCommandMetadata = defineFieldCommandMetadata(
NETWORK_COMMAND_NAME,
networkCommandDescription,
Expand All @@ -65,6 +81,11 @@ export const logsCommandDefinition = defineExecutableCommand(logsCommandMetadata
client.observability.logs(input),
);

export const eventsCommandDefinition = defineExecutableCommand(
eventsCommandMetadata,
(client, input) => client.observability.events(input),
);

export const networkCommandDefinition = defineExecutableCommand(
networkCommandMetadata,
(client, input) => client.observability.network(input),
Expand All @@ -85,6 +106,14 @@ const logsCliSchema = {
allowedFlags: ['restart'],
} as const satisfies CommandSchemaOverride;

const eventsCliSchema = {
usageOverride: 'events [limit] [cursor]',
listUsageOverride: 'events',
helpDescription: 'Read the daemon-owned session event timeline as paged JSON-friendly entries',
summary: 'Read session event timeline',
positionalArgs: ['limit?', 'cursor?'],
} as const satisfies CommandSchemaOverride;

const networkCliSchema = {
usageOverride:
'network dump [limit] [summary|headers|body|all] [--include summary|headers|body|all] | network log [limit] [summary|headers|body|all] [--include summary|headers|body|all]',
Expand Down Expand Up @@ -113,6 +142,12 @@ export const logsCliReader: CliReader = (positionals, flags) => ({
restart: flags.restart,
});

export const eventsCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
limit: positionals[0]?.trim() ? optionalCliNumber(positionals[0]) : undefined,
cursor: positionals[1],
});

export const networkCliReader: CliReader = (positionals, flags) => ({
...commonInputFromFlags(flags),
action: readNetworkAction(positionals[0]),
Expand All @@ -132,6 +167,9 @@ export const logsDaemonWriter: DaemonWriter = direct(LOGS_COMMAND_NAME, (input)
logsPositionals(input as LogsOptions),
);

export const eventsDaemonWriter: DaemonWriter = (input) =>
request(EVENTS_COMMAND_NAME, eventsPositionals(input as EventsOptions), input);

export const networkDaemonWriter: DaemonWriter = (input) =>
request(NETWORK_COMMAND_NAME, networkPositionals(input as NetworkOptions), {
...input,
Expand All @@ -151,6 +189,16 @@ const logsCommandFacet = defineCommandFacet({
cliOutputFormatter: observabilityCliOutputFormatters.logs,
});

const eventsCommandFacet = defineCommandFacet({
name: EVENTS_COMMAND_NAME,
metadata: eventsCommandMetadata,
definition: eventsCommandDefinition,
cliSchema: eventsCliSchema,
cliReader: eventsCliReader,
daemonWriter: eventsDaemonWriter,
cliOutputFormatter: observabilityCliOutputFormatters.events,
});

const networkCommandFacet = defineCommandFacet({
name: NETWORK_COMMAND_NAME,
metadata: networkCommandMetadata,
Expand All @@ -173,13 +221,18 @@ const audioCommandFacet = defineCommandFacet({

export const observabilityCommandFamily = defineCommandFamilyFromFacets({
name: 'observability',
commands: [logsCommandFacet, networkCommandFacet, audioCommandFacet],
commands: [logsCommandFacet, eventsCommandFacet, networkCommandFacet, audioCommandFacet],
});

function logsPositionals(input: { action?: string; message?: string }): string[] {
return [input.action ?? 'path', ...optionalString(input.message)];
}

function eventsPositionals(input: EventsOptions): string[] {
if (input.cursor === undefined) return optionalNumber(input.limit);
return [input.limit === undefined ? '' : String(input.limit), input.cursor];
}

function networkPositionals(input: NetworkOptions): string[] {
return [...(input.action ? [input.action] : []), ...optionalNumber(input.limit)];
}
Expand Down
Loading
Loading