From d755970f6e57c70beba39eeb37887d1395b03d88 Mon Sep 17 00:00:00 2001 From: Rod Mcnew Date: Fri, 16 Oct 2026 12:51:12 -0500 Subject: [PATCH] Add hidden historical app logs command Co-authored by AI (GPT-6) --- bin/get-graphql-schemas.js | 6 + graphql.config.ts | 5 +- packages/app/project.json | 14 + .../app-logs/generated/historical-logs.ts | 151 +++++ .../api/graphql/app-logs/generated/types.d.ts | 97 +++ .../app-logs/queries/historical-logs.graphql | 17 + .../app-management/generated/log-app.ts | 53 ++ .../app-management/queries/log-app.graphql | 6 + .../src/cli/commands/app/logs-next.test.ts | 312 ++++++++++ .../app/src/cli/commands/app/logs-next.ts | 40 ++ packages/app/src/cli/index.ts | 2 + .../src/cli/services/historical-logs/api.ts | 146 +++++ .../cli/services/historical-logs/catalog.ts | 42 ++ .../src/cli/services/historical-logs/flags.ts | 74 +++ .../services/historical-logs/input.test.ts | 158 +++++ .../src/cli/services/historical-logs/input.ts | 124 ++++ .../cli/services/historical-logs/presenter.ts | 50 ++ .../cli/services/historical-logs/response.ts | 14 + .../services/historical-logs/service.test.ts | 557 ++++++++++++++++++ .../cli/services/historical-logs/service.ts | 78 +++ .../src/cli/services/historical-logs/types.ts | 33 ++ packages/cli/oclif.manifest.json | 225 +++++++ packages/cli/src/cli/logs-next-help.test.ts | 49 ++ 23 files changed, 2252 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/cli/api/graphql/app-logs/generated/historical-logs.ts create mode 100644 packages/app/src/cli/api/graphql/app-logs/generated/types.d.ts create mode 100644 packages/app/src/cli/api/graphql/app-logs/queries/historical-logs.graphql create mode 100644 packages/app/src/cli/api/graphql/app-management/generated/log-app.ts create mode 100644 packages/app/src/cli/api/graphql/app-management/queries/log-app.graphql create mode 100644 packages/app/src/cli/commands/app/logs-next.test.ts create mode 100644 packages/app/src/cli/commands/app/logs-next.ts create mode 100644 packages/app/src/cli/services/historical-logs/api.ts create mode 100644 packages/app/src/cli/services/historical-logs/catalog.ts create mode 100644 packages/app/src/cli/services/historical-logs/flags.ts create mode 100644 packages/app/src/cli/services/historical-logs/input.test.ts create mode 100644 packages/app/src/cli/services/historical-logs/input.ts create mode 100644 packages/app/src/cli/services/historical-logs/presenter.ts create mode 100644 packages/app/src/cli/services/historical-logs/response.ts create mode 100644 packages/app/src/cli/services/historical-logs/service.test.ts create mode 100644 packages/app/src/cli/services/historical-logs/service.ts create mode 100644 packages/app/src/cli/services/historical-logs/types.ts create mode 100644 packages/cli/src/cli/logs-next-help.test.ts diff --git a/bin/get-graphql-schemas.js b/bin/get-graphql-schemas.js index 3cb493a992e..94293730d43 100755 --- a/bin/get-graphql-schemas.js +++ b/bin/get-graphql-schemas.js @@ -70,6 +70,12 @@ const schemas = [ pathToFile: 'areas/core/shopify/db/graphql/app_management_schema_unstable_public.graphql', localPaths: ['./packages/app/src/cli/api/graphql/app-management/app_management_schema.graphql'], }, + { + owner: 'shop', + repo: 'world', + pathToFile: 'areas/core/shopify/db/graphql/app_logs_schema_unstable_public.graphql', + localPaths: ['./packages/app/src/cli/api/graphql/app-logs/app_logs_schema.graphql'], + }, { owner: 'shop', repo: 'world', diff --git a/graphql.config.ts b/graphql.config.ts index 9b5ebbe6780..4dc5cae9cea 100644 --- a/graphql.config.ts +++ b/graphql.config.ts @@ -1,4 +1,4 @@ -function projectFactory(name: string, schemaName: string, project: string = 'app') { +function projectFactory(name: string, schemaName: string, project: string = 'app', scalars: Record = {}) { return { schema: `./packages/${project}/src/cli/api/graphql/${name}/${schemaName}`, documents: [ @@ -26,6 +26,7 @@ function projectFactory(name: string, schemaName: string, project: string = 'app PropertyPublicID: 'string', JSON: {input: 'JsonMapType | string', output: 'JsonMapType'}, URL: 'string', + ...scalars, }, }, }, @@ -50,6 +51,7 @@ function projectFactory(name: string, schemaName: string, project: string = 'app PropertyPublicID: 'string', JSON: {input: 'JsonMapType | string', output: 'JsonMapType'}, URL: 'string', + ...scalars, }, }, }, @@ -79,6 +81,7 @@ export default { businessPlatformOrganizations: projectFactory('business-platform-organizations', 'organizations_schema.graphql'), appDev: projectFactory('app-dev', 'app_dev_schema.graphql'), appManagement: projectFactory('app-management', 'app_management_schema.graphql'), + appLogs: projectFactory('app-logs', 'app_logs_schema.graphql', 'app', {DateTime: 'string'}), admin: projectFactory('admin', 'admin_schema.graphql', 'cli-kit'), bulkOperations: projectFactory('bulk-operations', 'admin_schema.graphql', 'cli-kit'), webhooks: projectFactory('webhooks', 'webhooks_schema.graphql'), diff --git a/packages/app/project.json b/packages/app/project.json index aabcf87980c..45fe66d3260 100644 --- a/packages/app/project.json +++ b/packages/app/project.json @@ -68,6 +68,7 @@ "{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/app-dev/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/app-logs/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" @@ -78,6 +79,7 @@ "pnpm eslint 'src/cli/api/graphql/business-platform-organizations/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/app-dev/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/app-management/generated/**/*.{ts,tsx}' --fix", + "pnpm eslint 'src/cli/api/graphql/app-logs/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/webhooks/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/functions/generated/**/*.{ts,tsx}' --fix", "pnpm eslint 'src/cli/api/graphql/admin/generated/**/*.{ts,tsx}' --fix" @@ -148,6 +150,15 @@ "cwd": "{workspaceRoot}" } }, + "graphql-codegen:generate:app-logs": { + "executor": "nx:run-commands", + "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/app-logs/**/*.graphql", "sharedGlobals"], + "outputs": ["{projectRoot}/src/cli/api/graphql/app-logs/generated/**/*.ts"], + "options": { + "commands": ["pnpm exec graphql-codegen --project=appLogs"], + "cwd": "{workspaceRoot}" + } + }, "graphql-codegen:generate:functions": { "executor": "nx:run-commands", "inputs": ["{workspaceRoot}/graphql.config.ts", "{projectRoot}/src/cli/api/graphql/functions/**/*.graphql", "sharedGlobals"], @@ -177,6 +188,7 @@ "graphql-codegen:generate:business-platform-organizations", "graphql-codegen:generate:app-dev", "graphql-codegen:generate:app-management", + "graphql-codegen:generate:app-logs", "graphql-codegen:generate:webhooks", "graphql-codegen:generate:functions", "graphql-codegen:generate:admin-as-app" @@ -187,6 +199,7 @@ "{projectRoot}/src/cli/api/graphql/business-platform-organizations/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/app-dev/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/app-management/generated/**/*.ts", + "{projectRoot}/src/cli/api/graphql/app-logs/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/webhooks/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/functions/generated/**/*.ts", "{projectRoot}/src/cli/api/graphql/admin/generated/**/*.ts" @@ -197,6 +210,7 @@ "find ./packages/app/src/cli/api/graphql/business-platform-organizations/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/app-dev/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/app-management/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", + "find ./packages/app/src/cli/api/graphql/app-logs/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/webhooks/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/functions/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;", "find ./packages/app/src/cli/api/graphql/admin/generated/ -type f -name '*.ts' -exec sh -c 'sed -i \"\" \"s|import \\* as Types from '\\''./types'\\'';|import \\* as Types from '\\''./types.js'\\'';|g; s|export const \\([A-Za-z0-9_]*\\)Document =|export const \\1 =|g\" \"$0\"' {} \\;" diff --git a/packages/app/src/cli/api/graphql/app-logs/generated/historical-logs.ts b/packages/app/src/cli/api/graphql/app-logs/generated/historical-logs.ts new file mode 100644 index 00000000000..a25026109da --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-logs/generated/historical-logs.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' +import {JsonMapType} from '@shopify/cli-kit/node/toml' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type HistoricalLogsQueryVariables = Types.Exact<{ + clientId: Types.Scalars['String']['input'] + types: Types.LogEventType[] | Types.LogEventType + timestamp: Types.TimestampRangeInput + limit: Types.Scalars['Int']['input'] + filters?: Types.InputMaybe + sortKey?: Types.InputMaybe + reverse?: Types.InputMaybe +}> + +export type HistoricalLogsQuery = { + app?: {logs?: {records: ({json: JsonMapType} | {json: JsonMapType})[]} | null} | null +} + +export const HistoricalLogs = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'HistoricalLogs'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'clientId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'types'}}, + type: { + kind: 'NonNullType', + type: { + kind: 'ListType', + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'LogEventType'}}}, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'timestamp'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'TimestampRangeInput'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'limit'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'Int'}}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'filters'}}, + type: { + kind: 'ListType', + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'LogFilterInput'}}}, + }, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'sortKey'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'LogSortKeys'}}, + }, + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'reverse'}}, + type: {kind: 'NamedType', name: {kind: 'Name', value: 'Boolean'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'app'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'clientId'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'clientId'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'logs'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'types'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'types'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'timestamp'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'timestamp'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'limit'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'limit'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'filters'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'filters'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'sortKey'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'sortKey'}}, + }, + { + kind: 'Argument', + name: {kind: 'Name', value: 'reverse'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'reverse'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: {kind: 'Name', value: 'records'}, + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'json'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/app-logs/generated/types.d.ts b/packages/app/src/cli/api/graphql/app-logs/generated/types.d.ts new file mode 100644 index 00000000000..c1e81814bf0 --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-logs/generated/types.d.ts @@ -0,0 +1,97 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions, @typescript-eslint/naming-convention, @typescript-eslint/no-explicit-any, tsdoc/syntax, @typescript-eslint/no-duplicate-type-constituents, @typescript-eslint/no-redundant-type-constituents, @nx/enforce-module-boundaries */ +import {JsonMapType} from '@shopify/cli-kit/node/toml' +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } + /** + * Represents an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601)-encoded date and time string. + * For example, 3:50 pm on September 7, 2019 in the time zone of UTC (Coordinated Universal Time) is + * represented as `"2019-09-07T15:50:00Z`". + */ + DateTime: { input: string; output: string; } + /** + * A [JSON](https://www.json.org/json-en.html) object. + * + * Example value: + * `{ + * "product": { + * "id": "gid://shopify/Product/1346443542550", + * "title": "White T-shirt", + * "options": [{ + * "name": "Size", + * "values": ["M", "L"] + * }] + * } + * }` + */ + JSON: { input: JsonMapType | string; output: JsonMapType; } +}; + +/** A log type. */ +export type LogEventType = + /** A GraphQL API request. */ + | 'GRAPHQL_REQUEST' + /** A webhook delivery. */ + | 'WEBHOOK_DELIVERY'; + +/** + * The fields logs can be filtered on. Each name matches a `shopify app logs` CLI + * flag or the record field it filters. A filter on a field that a log type doesn't + * have excludes that type's logs. + */ +export type LogFilterField = + /** The GraphQL operation name. Applies to GraphQL requests. */ + | 'OPERATION_NAME' + /** The GraphQL operation type, such as query or mutation. Applies to GraphQL requests. */ + | 'OPERATION_TYPE' + /** The API request ID. Applies to GraphQL requests. */ + | 'REQUEST_ID' + /** The result: SUCCESS or FAILURE, as in resultStatus. The CLI's --status flag. Applies to every log type. */ + | 'RESULT_STATUS' + /** The shop's myshopify.com domain, as in shopMyshopifyDomain. The CLI's --store flag. Applies to every log type. */ + | 'SHOP_MYSHOPIFY_DOMAIN' + /** The HTTP response status code, such as 500. Applies to webhook deliveries. */ + | 'STATUS_CODE' + /** The webhook topic, such as orders/create. Applies to webhook deliveries. */ + | 'TOPIC'; + +/** The input fields for one logs filter. All filters are combined with AND. */ +export type LogFilterInput = { + /** The field to filter on. */ + field: LogFilterField; + /** How to compare the field with the value. */ + operator: LogFilterOperator; + /** The value to compare with, at most 1024 bytes. */ + value: Scalars['String']['input']; +}; + +/** How a filter compares a field with its value. */ +export type LogFilterOperator = + /** Matches logs whose field equals the value. */ + | 'EQUALS' + /** Matches logs whose field doesn't equal the value, including logs with no value for the field. */ + | 'NOT_EQUALS'; + +/** The fields to sort logs by. */ +export type LogSortKeys = + /** Sort by timestamp. */ + | 'TIMESTAMP'; + +/** The input fields for a time range of up to 7 days within the last 30 days. */ +export type TimestampRangeInput = { + /** The start of the range, inclusive. */ + gte: Scalars['DateTime']['input']; + /** The end of the range, exclusive. */ + lt: Scalars['DateTime']['input']; +}; diff --git a/packages/app/src/cli/api/graphql/app-logs/queries/historical-logs.graphql b/packages/app/src/cli/api/graphql/app-logs/queries/historical-logs.graphql new file mode 100644 index 00000000000..1b6eb2b0e14 --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-logs/queries/historical-logs.graphql @@ -0,0 +1,17 @@ +query HistoricalLogs( + $clientId: String! + $types: [LogEventType!]! + $timestamp: TimestampRangeInput! + $limit: Int! + $filters: [LogFilterInput!] + $sortKey: LogSortKeys + $reverse: Boolean +) { + app(clientId: $clientId) { + logs(types: $types, timestamp: $timestamp, limit: $limit, filters: $filters, sortKey: $sortKey, reverse: $reverse) { + records { + json + } + } + } +} diff --git a/packages/app/src/cli/api/graphql/app-management/generated/log-app.ts b/packages/app/src/cli/api/graphql/app-management/generated/log-app.ts new file mode 100644 index 00000000000..e3b55a23fea --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-management/generated/log-app.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +import * as Types from './types.js' + +import {TypedDocumentNode as DocumentNode} from '@graphql-typed-document-node/core' + +export type LogAppQueryVariables = Types.Exact<{ + clientId: Types.Scalars['String']['input'] +}> + +export type LogAppQuery = {app: {key: string; organizationId: string}} + +export const LogApp = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: {kind: 'Name', value: 'LogApp'}, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: {kind: 'Variable', name: {kind: 'Name', value: 'clientId'}}, + type: {kind: 'NonNullType', type: {kind: 'NamedType', name: {kind: 'Name', value: 'String'}}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + alias: {kind: 'Name', value: 'app'}, + name: {kind: 'Name', value: 'appByKey'}, + arguments: [ + { + kind: 'Argument', + name: {kind: 'Name', value: 'key'}, + value: {kind: 'Variable', name: {kind: 'Name', value: 'clientId'}}, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + {kind: 'Field', name: {kind: 'Name', value: 'key'}}, + {kind: 'Field', name: {kind: 'Name', value: 'organizationId'}}, + {kind: 'Field', name: {kind: 'Name', value: '__typename'}}, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode diff --git a/packages/app/src/cli/api/graphql/app-management/queries/log-app.graphql b/packages/app/src/cli/api/graphql/app-management/queries/log-app.graphql new file mode 100644 index 00000000000..00765d0f1ed --- /dev/null +++ b/packages/app/src/cli/api/graphql/app-management/queries/log-app.graphql @@ -0,0 +1,6 @@ +query LogApp($clientId: String!) { + app: appByKey(key: $clientId) { + key + organizationId + } +} diff --git a/packages/app/src/cli/commands/app/logs-next.test.ts b/packages/app/src/cli/commands/app/logs-next.test.ts new file mode 100644 index 00000000000..8878a8ef3ce --- /dev/null +++ b/packages/app/src/cli/commands/app/logs-next.test.ts @@ -0,0 +1,312 @@ +import Logs from './logs-next.js' +import {retrieveLogs} from '../../services/historical-logs/service.js' +import {logsOutput, type LogsResult} from '../../services/historical-logs/types.js' +import {Config} from '@oclif/core' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' +import {mockAndCaptureOutput, withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' + +vi.mock('../../services/historical-logs/service.js', async (importOriginal) => ({ + ...(await importOriginal()), + retrieveLogs: vi.fn(), +})) + +const originalColumns = Object.getOwnPropertyDescriptor(process.stdout, 'columns') + +beforeEach(() => Object.defineProperty(process.stdout, 'columns', {value: 240, configurable: true})) + +afterEach(() => { + vi.unstubAllEnvs() + mockAndCaptureOutput().clear() + if (originalColumns) Object.defineProperty(process.stdout, 'columns', originalColumns) + else Reflect.deleteProperty(process.stdout, 'columns') +}) + +function result(): LogsResult { + return { + logs: [ + { + id: 'gid://shopify/AppLogRecord/example', + timestamp: '2026-10-06T13:30:00.123456789Z', + type: 'WEBHOOK_DELIVERY', + resultStatus: 'FAILURE', + shopMyshopifyDomain: 'example.myshopify.com', + topic: 'orders/create', + extra: {nested: true}, + }, + ], + ordering: 'subset-timestamp-asc' as const, + limitations: [ + 'Used fast retrieval: returned records sorted oldest first in the CLI, not necessarily the oldest or newest matches. Additional records may match your search.', + ], + } +} + +test('parses space/equals forms and forwards effective defaults', async () => { + vi.mocked(retrieveLogs).mockResolvedValue(result()) + await new Logs( + ['--client-id=explicit-app', '--type', 'webhook', '--topic=orders/create', '-s', 'example'], + await Config.load(), + ).run() + expect(retrieveLogs).toHaveBeenCalledWith( + expect.objectContaining({ + 'client-id': 'explicit-app', + type: ['webhook'], + topic: 'orders/create', + store: 'example', + since: '1h', + limit: 20, + }), + ) + expect(mockAndCaptureOutput().output()).toContain('Used fast retrieval: returned records shown oldest first') + expect(vi.mocked(retrieveLogs).mock.calls[0]![0].sort).toBeUndefined() + expect(vi.mocked(retrieveLogs).mock.calls[0]![0].order).toBeUndefined() +}) + +test('omitted type and repeated type flags are passed to the service without inventing filter OR', async () => { + vi.mocked(retrieveLogs).mockResolvedValue(result()) + await new Logs(['--client-id', 'explicit-app'], await Config.load()).run() + expect(vi.mocked(retrieveLogs).mock.calls[0]?.[0].type).toBeUndefined() + await new Logs(['--type', 'webhook', '--type', 'graphql', '--request-id', 'request-123'], await Config.load()).run() + expect(retrieveLogs).toHaveBeenLastCalledWith( + expect.objectContaining({ + type: ['webhook', 'graphql'], + 'request-id': 'request-123', + }), + ) +}) + +test.each([ + {argv: ['--sort', 'timestamp'], order: undefined}, + {argv: ['--sort=timestamp', '--order=asc'], order: 'asc'}, + {argv: ['--sort', 'timestamp', '--order', 'desc'], order: 'desc'}, +])('parses explicit sorting flags $argv', async ({argv, order}) => { + vi.mocked(retrieveLogs).mockResolvedValue(result()) + + await new Logs(argv, await Config.load()).run() + + const flags = vi.mocked(retrieveLogs).mock.calls[0]![0] + expect(flags.sort).toBe('timestamp') + expect(flags.order).toBe(order) +}) + +test('accepts sorting from environment variables and requires the sort key', async () => { + vi.stubEnv('SHOPIFY_FLAG_SORT', 'timestamp') + vi.stubEnv('SHOPIFY_FLAG_ORDER', 'desc') + vi.mocked(retrieveLogs).mockResolvedValue(result()) + + await new Logs([], await Config.load()).run() + + expect(retrieveLogs).toHaveBeenCalledWith(expect.objectContaining({sort: 'timestamp', order: 'desc'})) + vi.stubEnv('SHOPIFY_FLAG_SORT', undefined) + vi.mocked(retrieveLogs).mockClear() + await expect(new Logs([], await Config.load()).run()).rejects.toThrow() + expect(retrieveLogs).not.toHaveBeenCalled() +}) + +test.each([ + ['--client-id', 'one', '--client-id=two'], + ['--status', 'success', '--status=failure'], + ['--store', 'one', '--store=two'], + ['--topic', 'orders/create', '--topic=orders/delete'], + ['--operation-name', 'one', '--operation-name=two'], + ['--since', '1h', '--since=2h'], + ['--limit', '10', '--limit=20'], + ['--until', '2026-10-06T13:00:00Z', '--until=2026-10-06T14:00:00Z'], + ['--sort', 'timestamp', '--sort=timestamp'], + ['--sort', 'timestamp', '--order', 'asc', '--order=desc'], +])('rejects repeated singular flags %j before authentication', async (...argv) => { + await expect(new Logs(['--type', 'webhook', ...argv], await Config.load()).run()).rejects.toThrow( + 'can only be specified once', + ) + expect(retrieveLogs).not.toHaveBeenCalled() +}) + +test.each([ + ['--type', 'function'], + ['--sort', 'id'], + ['--sort', 'timestamp', '--order', 'ascending'], + ['--order', 'asc'], + ['--order', 'desc'], + ['--type', 'webhook', '--offset', '10'], + ['--type', 'graphql', '--client-ip', '123'], + ['--type', 'graphql', '--user-agent', 'test'], + ['--type', 'webhook', '--query', '{ app }'], +])('rejects missing or unsupported flags %j', async (...argv) => { + await expect(new Logs(argv, await Config.load()).run()).rejects.toThrow() + expect(retrieveLogs).not.toHaveBeenCalled() +}) + +test.each([ + {argv: ['--json'], json: true, noInput: false}, + {argv: ['--no-input'], json: false, noInput: true}, + {argv: ['--json', '--no-input'], json: true, noInput: true}, +])('keeps JSON and no-input independent ($argv)', async ({argv, json, noInput}) => { + vi.mocked(retrieveLogs).mockResolvedValue(result()) + await new Logs(['--type', 'webhook', ...argv], await Config.load()).run() + expect(vi.mocked(retrieveLogs).mock.calls[0]![0]['no-input'] ?? false).toBe(noInput) + expect(mockAndCaptureOutput().output().startsWith('{')).toBe(json) + if (json) expect(JSON.parse(mockAndCaptureOutput().output())).toEqual(result()) +}) + +test('JSON writes one complete object without query or pagination metadata or terminal text', async () => { + vi.mocked(retrieveLogs).mockResolvedValue(result()) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new Logs(['--type', 'webhook', '--json', '--no-input'], await Config.load()).run() + expect(JSON.parse(stdout())).toEqual(result()) + expect(JSON.parse(stdout())).not.toHaveProperty('query') + expect(JSON.parse(stdout())).not.toHaveProperty('pageInfo') + expect(stderr()).toBe('') + }) +}) + +test('webhook-only human output omits the inapplicable API column', async () => { + vi.mocked(retrieveLogs).mockResolvedValue(result()) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new Logs(['--type', 'webhook'], await Config.load()).run() + expect(stdout()).toBe('') + const lines = stderr().trim().split('\n') + expect(lines[1]!.trim().split(/ {2,}/)).toEqual(['Timestamp', 'Type', 'Result', 'Store', 'Topic / operation']) + expect(lines[3]!.trim().split(/ {2,}/)).toEqual([ + '2026-10-06T13:30:00.123456789Z', + 'WEBHOOK_DELIVERY', + 'FAILURE', + 'example.myshopify.com', + 'orders/create', + ]) + }) +}) + +test.each([ + ['timestamp-asc', 'Oldest first'], + ['timestamp-desc', 'Newest first'], +] as const)('renders %s without result disclaimers in table and JSON output', async (ordering, label) => { + const data = { + ...result(), + ordering, + limitations: [], + } + vi.mocked(retrieveLogs).mockResolvedValue(data) + + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new Logs([], await Config.load()).run() + expect(stdout()).toBe('') + expect(stderr().split('\n')[0]).toBe(`${label} by timestamp.`) + expect(stderr()).not.toContain('Additional records may match your search.') + expect(stderr()).not.toContain('Used fast retrieval') + expect(stderr()).toContain('orders/create') + }) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new Logs(['--json'], await Config.load()).run() + expect(JSON.parse(stdout())).toEqual(data) + expect(stderr()).toBe('') + }) +}) + +test.each(['ADMIN', undefined])('GraphQL-only human output keeps the API column for %s', async (api) => { + const data = result() + data.logs = [{...data.logs[0]!, type: 'GRAPHQL_REQUEST', topic: undefined, operationName: 'MyQuery', api}] + vi.mocked(retrieveLogs).mockResolvedValue(data) + await withCapturedStandardStreams(async ({stderr}) => { + await new Logs(['--type', 'graphql'], await Config.load()).run() + const lines = stderr().trim().split('\n') + expect(lines[1]!.trim().split(/ {2,}/).at(-1)).toBe('API') + expect(lines[3]!.trim().split(/ {2,}/).slice(-2)).toEqual(['MyQuery', api ?? 'unknown']) + }) +}) + +test('human output aligns headers with mixed log rows between the warning and JSON hint', async () => { + const data = result() + data.logs.push({ + id: 'gid://shopify/AppLogRecord/another', + timestamp: '2026-10-06T13:31:00Z', + type: 'GRAPHQL_REQUEST', + resultStatus: 'SUCCESS', + shopMyshopifyDomain: 'longer-example.myshopify.com', + operationName: 'QueryWithALongerOperationName', + api: 'STOREFRONT', + }) + vi.mocked(retrieveLogs).mockResolvedValue(data) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new Logs([], await Config.load()).run() + expect(stdout()).toBe('') + const lines = stderr().trim().split('\n') + expect(lines[0]).toBe( + 'Used fast retrieval: returned records shown oldest first, not necessarily the oldest matching records.', + ) + expect(lines.at(-1)).toBe('Use --json for full records.') + expect(lines).toHaveLength(6) + const headers = ['Timestamp', 'Type', 'Result', 'Store', 'Topic / operation', 'API'] + expect(lines[1]!.trim().split(/ {2,}/)).toEqual(headers) + for (const [index, record] of data.logs.entries()) { + const values = [ + record.timestamp, + record.type, + record.resultStatus, + record.shopMyshopifyDomain, + record.topic ?? record.operationName, + record.api ?? '—', + ] + const row = lines[index + 3]! + expect(row.trim().split(/ {2,}/)).toEqual(values) + for (const [column, header] of headers.entries()) { + expect(row.indexOf(String(values[column]))).toBe(lines[1]!.indexOf(header)) + } + } + }) +}) + +test('empty human output keeps the warning and empty-result message without a table or footer', async () => { + vi.mocked(retrieveLogs).mockResolvedValue({...result(), logs: []}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new Logs(['--type', 'webhook'], await Config.load()).run() + expect(stdout()).toBe('') + expect(stderr().trim().split('\n')).toEqual([ + 'Used fast retrieval: returned records shown oldest first, not necessarily the oldest matching records.', + 'No matching records returned.', + ]) + }) +}) + +test('human output marks abbreviated cells and does not print body data', async () => { + const data = result() + data.logs[0]!.topic = `orders/${'a'.repeat(150)}` + data.logs[0]!.requestBody = 'not-in-human-summary' + vi.mocked(retrieveLogs).mockResolvedValue(data) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await new Logs(['--type', 'webhook'], await Config.load()).run() + expect(stdout()).toBe('') + expect(stderr()).toContain(`orders/${'a'.repeat(92)}…`) + expect(stderr()).toContain('Use --json for full records.') + expect(stderr()).not.toContain('not-in-human-summary') + }) + expect(JSON.parse(logsOutput.encode(data)).logs).toEqual(data.logs) +}) + +test('escapes terminal control characters in human output without changing JSON', async () => { + const data = result() + data.logs[0]!.topic = 'orders/\u001b[31mred\u001b[0m\nnext\u202eline' + vi.mocked(retrieveLogs).mockResolvedValue(data) + await withCapturedStandardStreams(async ({stderr}) => { + await new Logs(['--type', 'webhook'], await Config.load()).run() + expect(stderr()).toContain('orders/red\\u000anext\\u202eline') + expect(stderr()).not.toContain('\u001b[31m') + }) + expect(JSON.parse(logsOutput.encode(data)).logs).toEqual(data.logs) +}) + +test('publishes a strict envelope and an extensible native record schema', () => { + expect(Logs.jsonOutputSchema).toBe(logsOutput) + expect(Logs.descriptionForHelp()).toContain('HistoricalAppLogs') + expect(() => logsOutput.validate({...result(), accidental: true})).toThrow() + expect(() => logsOutput.validate({...result(), query: {clientId: 'test-app'}})).toThrow() + expect(JSON.parse(logsOutput.encode(result())).logs[0].extra).toEqual({nested: true}) + expect(logsOutput.jsonSchema).toHaveProperty('properties.logs') + expect(logsOutput.jsonSchema).toHaveProperty('properties.ordering.enum', [ + 'subset-timestamp-asc', + 'timestamp-asc', + 'timestamp-desc', + ]) + expect(logsOutput.jsonSchema).not.toHaveProperty('properties.query') + expect(logsOutput.jsonSchema).not.toHaveProperty('properties.pageInfo') + expect(() => logsOutput.validate({...result(), pageInfo: {limit: 20}})).toThrow() +}) diff --git a/packages/app/src/cli/commands/app/logs-next.ts b/packages/app/src/cli/commands/app/logs-next.ts new file mode 100644 index 00000000000..6ce225155d6 --- /dev/null +++ b/packages/app/src/cli/commands/app/logs-next.ts @@ -0,0 +1,40 @@ +import {historicalLogFlags} from '../../services/historical-logs/flags.js' +import {retrieveLogs} from '../../services/historical-logs/service.js' +import {renderHistoricalLogs} from '../../services/historical-logs/presenter.js' +import {logsOutput} from '../../services/historical-logs/types.js' +import BaseCommand from '@shopify/cli-kit/node/base-command' +import {authAliasFlag, globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {noInputFlag} from '@shopify/cli-kit/node/no-input' + +export default class Logs extends BaseCommand { + static hidden = true + static baseFlags = {...BaseCommand.baseFlags, ...authAliasFlag} + static summary = 'Lists historical app logs.' + static descriptionWithMarkdown = `Returns a list of historical app logs and exits. Defaults to all supported log types. Use --type to select a type; repeat it to select multiple. + +Fast retrieval is the default: leave out --sort. Prefer this mode with a narrow time window and selective filters to consume less throttle capacity. The CLI orders the returned records oldest first, but they aren't necessarily the oldest or newest matches. + +Use server-side sorting only when selecting the oldest or newest matching records matters: --sort timestamp selects oldest first; add --order desc for newest first. This sorts before the result limit and is subject to API query limits. + +Different filters combine with AND; each accepts one exact value. Type-specific filters exclude records without that field; combining --topic and --operation-name returns no records. + +Defaults: preceding hour, 20 records. Maximum: seven-day window within thirty days, 1000 records. Time inputs accept whole seconds only. + +--json preserves native record JSON in logs[], including fractional timestamps, diagnostic fields, bodies, and producer truncation flags.` + static description = this.descriptionForHelp() + static flags = {...globalFlags, ...jsonFlag, ...noInputFlag, ...historicalLogFlags} + static examples = [ + '<%= config.bin %> <%= command.id %> --client-id APP_CLIENT_ID --type webhook --topic orders/create --status failure --since 30m', + '<%= config.bin %> <%= command.id %> --type graphql --operation-name MyQuery --json --no-input', + '<%= config.bin %> <%= command.id %> --since 1h --sort timestamp --order desc', + ] + + static get jsonOutputSchema() { + return logsOutput + } + + async run(): Promise { + const {flags} = await this.parse(Logs) + renderHistoricalLogs(await retrieveLogs(flags), flags.json) + } +} diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index 38df624e2e4..5a6a5762363 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -13,6 +13,7 @@ import SecurityInstructions from './commands/app/security/instructions.js' import SecurityRecord from './commands/app/security/record.js' import SecurityReview from './commands/app/security/review.js' import Logs from './commands/app/logs.js' +import LogsNext from './commands/app/logs-next.js' import Sources from './commands/app/app-logs/sources.js' import EnvPull from './commands/app/env/pull.js' import EnvShow from './commands/app/env/show.js' @@ -67,6 +68,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:security:record': SecurityRecord, 'app:security:review': SecurityReview, 'app:logs': Logs, + 'app:logs-next': LogsNext, 'app:logs:sources': Sources, 'app:import:channel-config': ImportChannelConfig, 'app:import:custom-data-definitions': ImportCustomDataDefinitions, diff --git a/packages/app/src/cli/services/historical-logs/api.ts b/packages/app/src/cli/services/historical-logs/api.ts new file mode 100644 index 00000000000..6fbc650e30a --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/api.ts @@ -0,0 +1,146 @@ +import {logError} from './catalog.js' +import {LogApp} from '../../api/graphql/app-management/generated/log-app.js' +import {TypedDocumentNode} from '@graphql-typed-document-node/core' +import {appManagementHeaders, appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' +import {fetch} from '@shopify/cli-kit/node/http' +import {zod} from '@shopify/cli-kit/node/schema' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {resolveRequestDocument} from 'graphql-request' + +const graphqlError = zod.object({ + message: zod.string(), + path: zod.array(zod.union([zod.string(), zod.number()])).optional(), + extensions: zod.record(zod.unknown()).optional(), +}) +const responseSchema = zod.object({ + data: zod.record(zod.unknown()).nullable().optional(), + errors: zod.array(graphqlError.passthrough()).optional(), + extensions: zod.record(zod.unknown()).optional(), +}) + +interface LogsConnection { + clientId: string + origin: string + token: string + organizationId: string +} + +export async function connectLogs(clientId: string, noInput: boolean): Promise { + if (getAppAutomationToken()) + throw logError( + 'AUTHENTICATION_REQUIRED', + 'Sign in with a user account to retrieve app logs. Automation tokens are not supported.', + ) + const host = await appManagementFqdn() + let {appManagementToken: token} = await ensureAuthenticatedAppManagementAndBusinessPlatform({noPrompt: noInput}) + const origin = `https://${host}` + const result = await appManagementRequestDoc({ + query: LogApp, + token, + variables: {clientId}, + unauthorizedHandler: { + type: 'token_refresh', + handler: async () => { + const session = await ensureAuthenticatedAppManagementAndBusinessPlatform({ + noPrompt: noInput, + forceRefresh: true, + }) + token = session.appManagementToken + return {token} + }, + }, + }) + const app = zod + .object({ + key: zod.string(), + organizationId: zod + .string() + .regex(/^gid:\/\/shopify\/Organization\/\d+$/) + .transform((gid) => gid.split('/').at(-1)!), + }) + .safeParse(result.app) + if (!app.success || app.data.key !== clientId) { + throw logError('FORBIDDEN', 'The selected app was not resolved to an authorized organization.') + } + return {clientId, origin, token, organizationId: app.data.organizationId} +} + +export async function logsRequest>( + connection: LogsConnection, + query: TypedDocumentNode, + variables: NoInfer, +) { + return requestGraphql( + connection.origin, + `/app_logs/unstable/organizations/${connection.organizationId}/graphql`, + connection.token, + resolveRequestDocument(query).query, + variables, + ) +} + +async function requestGraphql( + origin: string, + path: string, + token: string, + query: string, + variables: Record, +) { + const response = await fetch(`${origin}${path}`, { + method: 'POST', + redirect: 'error', + signal: AbortSignal.timeout(45000), + headers: appManagementHeaders(token), + body: JSON.stringify({query, variables}), + }).catch((error: unknown) => { + throwIfTimedOut(error) + throw error + }) + if (!response.ok) { + const code = + ( + {401: 'AUTHENTICATION_REQUIRED', 403: 'FORBIDDEN', 429: 'RATE_LIMITED', 504: 'QUERY_TIMEOUT'} as Record< + number, + string + > + )[response.status] ?? 'LOGS_API_UNAVAILABLE' + const error = logError( + code, + `App Logs request failed (HTTP ${response.status}). Check API availability, access, and authentication.`, + ) + const retryAfter = response.headers.get('retry-after') + if (retryAfter && /^\d+$/.test(retryAfter)) error.details = {retryAfterSeconds: Number(retryAfter)} + throw error + } + const body: unknown = await response.json().catch((error: unknown) => { + throwIfTimedOut(error) + throw logError('INVALID_RESPONSE', 'App Logs returned a response that could not be read as JSON.') + }) + const result = responseSchema.safeParse(body) + if (!result.success || (result.data.data === undefined && result.data.errors === undefined)) { + throw logError('INVALID_RESPONSE', 'App Logs returned an invalid GraphQL response.') + } + return result.data +} + +function throwIfTimedOut(error: unknown): void { + if (error instanceof Error && ['AbortError', 'TimeoutError'].includes(error.name)) { + throw logError( + 'QUERY_TIMEOUT', + 'App Logs request timed out. Check backend availability or use a narrower window or more selective filters.', + ) + } +} + +export function failOnErrors(result: zod.infer) { + if (!result.errors?.length) return + const error = logError('LOG_QUERY_FAILED', result.errors.map(({message}) => message).join('\n')) + error.details = { + errors: result.errors, + ...(result.data === undefined ? {} : {data: result.data}), + ...(result.extensions === undefined ? {} : {extensions: result.extensions}), + } + throw error +} diff --git a/packages/app/src/cli/services/historical-logs/catalog.ts b/packages/app/src/cli/services/historical-logs/catalog.ts new file mode 100644 index 00000000000..7f1f18a12d0 --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/catalog.ts @@ -0,0 +1,42 @@ +import {AbortError} from '@shopify/cli-kit/node/error' + +const logTypes = [ + {name: 'webhook', backend: 'WEBHOOK_DELIVERY', description: 'Webhook delivery attempts.'}, + { + name: 'graphql', + backend: 'GRAPHQL_REQUEST', + description: 'GraphQL API requests across API targets, not only Admin.', + }, +] as const + +export const logFilters = [ + {flag: 'store', field: 'SHOP_MYSHOPIFY_DOMAIN', types: ['webhook', 'graphql']}, + {flag: 'status', field: 'RESULT_STATUS', types: ['webhook', 'graphql']}, + {flag: 'topic', field: 'TOPIC', types: ['webhook']}, + {flag: 'status-code', field: 'STATUS_CODE', types: ['webhook']}, + {flag: 'operation-type', field: 'OPERATION_TYPE', types: ['graphql']}, + {flag: 'operation-name', field: 'OPERATION_NAME', types: ['graphql']}, + {flag: 'request-id', field: 'REQUEST_ID', types: ['graphql']}, +] as const + +export const queryLimits = { + defaultLimit: 20, + defaultSince: '1h', + maximumLimit: 1000, +} + +export function logError(code: string, message: string): AbortError { + const error = new AbortError(message) + error.code = code + return error +} + +function selectedLogType(value?: string) { + const type = logTypes.find(({name}) => name === value) + if (!type) throw logError('INVALID_ARGUMENT', 'Supported log types are webhook and graphql.') + return type +} + +export function selectedLogTypes(values?: string[]) { + return values === undefined ? [...logTypes] : [...new Set(values)].map(selectedLogType) +} diff --git a/packages/app/src/cli/services/historical-logs/flags.ts b/packages/app/src/cli/services/historical-logs/flags.ts new file mode 100644 index 00000000000..63a1591b968 --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/flags.ts @@ -0,0 +1,74 @@ +import {appFlags} from '../../flags.js' +import {Flags} from '@oclif/core' + +export const historicalLogFlags = { + path: Flags.string({description: 'Path to your app directory.', env: 'SHOPIFY_FLAG_PATH'}), + config: appFlags.config, + 'client-id': Flags.string({ + description: 'App client ID; takes precedence over local app configuration.', + env: 'SHOPIFY_FLAG_CLIENT_ID', + }), + type: Flags.string({ + description: 'Log type; repeat to select multiple. Defaults to all supported types.', + options: ['webhook', 'graphql'], + multiple: true, + multipleNonGreedy: true, + env: 'SHOPIFY_FLAG_TYPE', + }), + store: Flags.string({ + description: 'Exact store handle or myshopify.com domain/URL.', + char: 's', + env: 'SHOPIFY_FLAG_STORE', + }), + status: Flags.string({ + description: 'Recorded result status; not inferred from HTTP codes or response bodies.', + options: ['success', 'failure'], + env: 'SHOPIFY_FLAG_STATUS', + }), + topic: Flags.string({ + description: 'Exact webhook topic; excludes non-webhook records.', + env: 'SHOPIFY_FLAG_TOPIC', + }), + 'status-code': Flags.string({ + description: 'Exact webhook HTTP status code (100–599); no status classes.', + env: 'SHOPIFY_FLAG_STATUS_CODE', + }), + 'operation-type': Flags.string({ + description: 'Exact GraphQL operation type; excludes non-GraphQL records.', + env: 'SHOPIFY_FLAG_OPERATION_TYPE', + }), + 'operation-name': Flags.string({ + description: 'Exact GraphQL operation name; excludes non-GraphQL records.', + env: 'SHOPIFY_FLAG_OPERATION_NAME', + }), + 'request-id': Flags.string({ + description: 'Exact GraphQL request ID; excludes non-GraphQL records.', + env: 'SHOPIFY_FLAG_REQUEST_ID', + }), + since: Flags.string({ + description: 'Inclusive start: positive duration (s/m/h/d) or whole-second RFC 3339 timestamp.', + default: '1h', + env: 'SHOPIFY_FLAG_SINCE', + }), + until: Flags.string({ + description: 'Exclusive whole-second RFC 3339 end; defaults to the current time.', + env: 'SHOPIFY_FLAG_UNTIL', + }), + limit: Flags.integer({ + description: 'Maximum records, from 1 through 1000; fast retrieval does not select the oldest or newest matches.', + default: 20, + env: 'SHOPIFY_FLAG_LIMIT', + }), + sort: Flags.string({ + description: + 'Select the oldest/newest matches using server-side sorting. Omit for faster, less-throttled retrieval.', + options: ['timestamp'], + env: 'SHOPIFY_FLAG_SORT', + }), + order: Flags.string({ + description: 'Sort direction: asc (oldest first, default with --sort) or desc (newest first).', + options: ['asc', 'desc'], + dependsOn: ['sort'], + env: 'SHOPIFY_FLAG_ORDER', + }), +} diff --git a/packages/app/src/cli/services/historical-logs/input.test.ts b/packages/app/src/cli/services/historical-logs/input.test.ts new file mode 100644 index 00000000000..28061c12628 --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/input.test.ts @@ -0,0 +1,158 @@ +import {prepareLogInput, resolveLogWindow, type LogInput} from './input.js' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' + +beforeEach(() => vi.useFakeTimers({toFake: ['Date'], now: new Date('2026-10-06T14:00:00Z')})) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +function window(input: Pick = {}) { + return resolveLogWindow(prepareLogInput(input).window) +} + +test('defaults to both types and a bounded shared limit', () => { + expect(prepareLogInput({})).toMatchObject({ + types: [{backend: 'WEBHOOK_DELIVERY'}, {backend: 'GRAPHQL_REQUEST'}], + limit: 20, + filters: [], + }) + expect(prepareLogInput({type: ['graphql', 'graphql']}).types).toEqual([ + expect.objectContaining({backend: 'GRAPHQL_REQUEST'}), + ]) +}) + +test.each([ + [{}, 'none'], + [{sort: 'timestamp'}, 'timestamp-asc'], + [{sort: 'timestamp', order: 'asc'}, 'timestamp-asc'], + [{sort: 'timestamp', order: 'desc'}, 'timestamp-desc'], +] satisfies [LogInput, string][])('resolves ordering for %j without sorting by default', (input, ordering) => { + expect(prepareLogInput(input).ordering).toBe(ordering) +}) + +test('normalizes the store and sends exact predicates to the server before the limit', () => { + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') + expect( + prepareLogInput({ + type: ['webhook'], + store: 'https://EXAMPLE.myshopify.com/admin', + topic: 'orders/create', + status: 'failure', + 'status-code': '500', + }).filters, + ).toEqual([ + {field: 'SHOP_MYSHOPIFY_DOMAIN', operator: 'EQUALS', value: 'example.myshopify.com'}, + {field: 'RESULT_STATUS', operator: 'EQUALS', value: 'FAILURE'}, + {field: 'TOPIC', operator: 'EQUALS', value: 'orders/create'}, + {field: 'STATUS_CODE', operator: 'EQUALS', value: '500'}, + ]) + expect(prepareLogInput({store: 'EXAMPLE'}).filters).toEqual([ + {field: 'SHOP_MYSHOPIFY_DOMAIN', operator: 'EQUALS', value: 'example.myshopify.com'}, + ]) +}) + +test('maps every GraphQL flag without assuming Admin API or parsing opaque values', () => { + expect( + prepareLogInput({ + type: ['graphql'], + status: 'success', + 'operation-name': 'MyQuery', + 'operation-type': 'query', + 'request-id': 'opaque,request-id', + }).filters, + ).toEqual([ + {field: 'RESULT_STATUS', operator: 'EQUALS', value: 'SUCCESS'}, + {field: 'OPERATION_TYPE', operator: 'EQUALS', value: 'query'}, + {field: 'OPERATION_NAME', operator: 'EQUALS', value: 'MyQuery'}, + {field: 'REQUEST_ID', operator: 'EQUALS', value: 'opaque,request-id'}, + ]) +}) + +test.each([ + [{type: ['graphql,webhook']}, 'Supported log types'], + [{type: ['function']}, 'Supported log types'], + [{type: []}, 'at least one'], + [{'status-code': '5xx'}, 'exact code'], + [{topic: 'orders/*'}, 'slash-form'], + [{store: 'custom.example.com'}, 'myshopify.com'], + [{status: 'unknown'}, 'success or failure'], + [{topic: ' '}, 'nonempty'], + [{limit: 0}, '--limit'], + [{limit: 1001}, '--limit'], + [{limit: 1.5}, '--limit'], + [{sort: 'id'}, '--sort'], + [{sort: 'timestamp', order: 'ascending'}, '--order'], + [{order: 'asc'}, '--order requires --sort'], + [{order: 'desc'}, '--order requires --sort'], +] satisfies [LogInput, string][])('rejects unsupported query %j', (input, message) => { + expect(() => prepareLogInput(input)).toThrow(message) +}) + +test('preserves all AND filters even when their types do not overlap', () => { + expect(prepareLogInput({topic: 'orders/create', 'operation-name': 'MyQuery'}).filters).toEqual([ + {field: 'TOPIC', operator: 'EQUALS', value: 'orders/create'}, + {field: 'OPERATION_NAME', operator: 'EQUALS', value: 'MyQuery'}, + ]) + expect(prepareLogInput({type: ['graphql'], topic: 'orders/create'}).filters).toEqual([ + {field: 'TOPIC', operator: 'EQUALS', value: 'orders/create'}, + ]) +}) + +test('enforces the byte limit without truncating filter values', () => { + expect(prepareLogInput({'operation-name': 'é'.repeat(512)}).filters[0]?.value).toBe('é'.repeat(512)) + expect(() => prepareLogInput({'operation-name': 'é'.repeat(513)})).toThrow('1024 UTF-8 bytes') +}) + +test('anchors the default hour to the local clock truncated to whole seconds', () => { + vi.setSystemTime(new Date('2026-10-06T14:02:03.987Z')) + expect(window()).toEqual({ + since: '2026-10-06T13:02:03Z', + until: '2026-10-06T14:02:03Z', + }) +}) + +test('normalizes whole-second offset inputs to UTC and makes since relative to until', () => { + expect(window({until: '2026-10-06T09:30:00-04:00', since: '1h'})).toMatchObject({ + until: '2026-10-06T13:30:00Z', + since: '2026-10-06T12:30:00Z', + }) +}) + +test.each([ + [{since: '8d'}, {since: '2026-09-28T14:00:00Z', until: '2026-10-06T14:00:00Z'}], + [ + {since: '2026-09-01T14:00:00Z', until: '2026-09-09T14:00:00Z'}, + {since: '2026-09-01T14:00:00Z', until: '2026-09-09T14:00:00Z'}, + ], + [{until: '2026-10-06T14:02:00Z'}, {since: '2026-10-06T13:02:00Z', until: '2026-10-06T14:02:00Z'}], +])('leaves time-policy eligibility to the server for %j', (input, expected) => { + expect(window(input)).toEqual(expected) +}) + +test.each([ + {since: '0h'}, + {since: '-1h'}, + {since: '1.5h'}, + {since: '999999999999999999d'}, + {since: '100000000000000s'}, + {since: '2026-10-06'}, + {since: '2026-10-06T13:00:00'}, + {since: '2026-09-31T13:00:00Z'}, + {since: '2026-10-06T24:00:00Z'}, + {since: '2026-10-06T13:00:00+25:00'}, + {until: '2026-10-06T14:00:00.000Z'}, + {until: '2026-10-06T14:00:00.123456789Z'}, + {since: '2026-10-06T13:00:00Z', until: '2026-10-06T13:00:00Z'}, + {since: '2026-10-06T14:00:00Z', until: '2026-10-06T13:00:00Z'}, + {since: '2026-10-06T14:00:00Z'}, +])('rejects invalid or empty windows %j', (input) => { + expect(() => window(input)).toThrow(expect.objectContaining({code: 'INVALID_ARGUMENT'})) +}) + +test('preserves explicit bounds independently of the current time', () => { + expect(window({since: '2026-10-05T09:00:00-04:00', until: '2026-10-05T10:00:00-04:00'})).toEqual({ + since: '2026-10-05T13:00:00Z', + until: '2026-10-05T14:00:00Z', + }) +}) diff --git a/packages/app/src/cli/services/historical-logs/input.ts b/packages/app/src/cli/services/historical-logs/input.ts new file mode 100644 index 00000000000..3447c26f27f --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/input.ts @@ -0,0 +1,124 @@ +import {logError, logFilters, queryLimits, selectedLogTypes} from './catalog.js' +import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {formatJsonOutputTimestamp} from '@shopify/cli-kit/node/json-output-schema' +import type {LogFilterInput} from '../../api/graphql/app-logs/generated/types.js' + +export interface LogInput { + type?: string[] + store?: string + status?: string + topic?: string + 'status-code'?: string + 'operation-type'?: string + 'operation-name'?: string + 'request-id'?: string + since?: string + until?: string + limit?: number + sort?: string + order?: string +} + +export function prepareLogInput(input: LogInput) { + const types = selectedLogTypes(input.type) + if (!types.length) throw logError('INVALID_ARGUMENT', 'Select at least one log type.') + const limit = input.limit ?? queryLimits.defaultLimit + if (!Number.isInteger(limit) || limit < 1 || limit > queryLimits.maximumLimit) { + throw logError('INVALID_ARGUMENT', '--limit must be an integer between 1 and 1000.') + } + if (input.sort !== undefined && input.sort !== 'timestamp') + throw logError('INVALID_ARGUMENT', '--sort accepts timestamp.') + if (input.order !== undefined && !['asc', 'desc'].includes(input.order)) + throw logError('INVALID_ARGUMENT', '--order accepts asc or desc.') + if (input.order !== undefined && input.sort === undefined) + throw logError('INVALID_ARGUMENT', '--order requires --sort.') + const timestampOrdering = input.order === 'desc' ? 'timestamp-desc' : 'timestamp-asc' + const ordering = input.sort === undefined ? 'none' : timestampOrdering + const values: Partial> = {} + for (const definition of logFilters) { + const value = input[definition.flag] + if (value === undefined) continue + if (typeof value !== 'string' || !value.trim()) + throw logError('INVALID_ARGUMENT', `--${definition.flag} requires one nonempty value.`) + if (Buffer.byteLength(value, 'utf8') > 1024) + throw logError('INVALID_ARGUMENT', `--${definition.flag} accepts at most 1024 UTF-8 bytes.`) + values[definition.flag] = value + } + if (values.store) { + const domain = normalizeStoreFqdn( + /^[a-zA-Z0-9][a-zA-Z0-9-]*$/.test(values.store.trim()) ? `${values.store.trim()}.myshopify.com` : values.store, + ) + if (!/^[a-z0-9][a-z0-9-]*\.myshopify\.com$/.test(domain)) + throw logError('INVALID_ARGUMENT', 'Historical logs require a myshopify.com store domain.') + values.store = domain + } + if (values.status && !['success', 'failure'].includes(values.status)) + throw logError('INVALID_ARGUMENT', '--status accepts success or failure.') + if (values.topic && !/^[a-zA-Z0-9_.-]+\/[a-zA-Z0-9_./-]+$/.test(values.topic)) + throw logError('INVALID_ARGUMENT', '--topic requires an exact slash-form topic, such as orders/create.') + if (values['status-code'] && !/^[1-5]\d{2}$/.test(values['status-code'])) + throw logError('INVALID_ARGUMENT', '--status-code accepts an exact code from 100 to 599, not a status class.') + + const filters: LogFilterInput[] = logFilters.flatMap(({flag, field}) => { + const value = values[flag] + return value === undefined + ? [] + : [{field, operator: 'EQUALS', value: flag === 'status' ? value.toUpperCase() : value}] + }) + const window = prepareLogWindow(input) + return { + types, + filters, + limit, + ordering, + window, + } +} + +function prepareLogWindow(input: Pick) { + const since = input.since ?? queryLimits.defaultSince + const duration = /^(\d+)(s|m|h|d)$/.exec(since) + const units = new Map([ + ['s', 1], + ['m', 60], + ['h', 3600], + ['d', 86400], + ]) + const durationSeconds = duration ? Number(duration[1]) * units.get(duration[2]!)! : undefined + if (durationSeconds !== undefined && (!Number.isSafeInteger(durationSeconds) || durationSeconds <= 0)) + throw logError('INVALID_ARGUMENT', '--since requires a positive integer duration or a whole-second timestamp.') + const start = durationSeconds === undefined ? parseTimestamp(since, '--since') : undefined + const end = input.until === undefined ? undefined : parseTimestamp(input.until, '--until') + if (start !== undefined && end !== undefined && start >= end) + throw logError('INVALID_ARGUMENT', '--since must be earlier than --until.') + return {start, end, durationSeconds} +} + +export function resolveLogWindow(window: ReturnType) { + const anchor = Math.floor(Date.now() / 1000) * 1000 + const end = window.end ?? anchor + const start = window.start ?? end - window.durationSeconds! * 1000 + if (!Number.isFinite(new Date(start).valueOf())) + throw logError('INVALID_ARGUMENT', '--since produces a timestamp outside the supported date range.') + if (start >= end) + throw logError('INVALID_ARGUMENT', '--since must be earlier than --until (the current time by default).') + return { + since: formatJsonOutputTimestamp(new Date(start)), + until: formatJsonOutputTimestamp(new Date(end)), + } +} + +function parseTimestamp(value: string, flag: string): number { + const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(Z|[+-]\d{2}:\d{2})$/.exec(value) + const invalid = () => + logError( + 'INVALID_ARGUMENT', + `${flag} requires a whole-second RFC 3339 timestamp with a timezone; fractional input is not supported by the API.`, + ) + if (!match) throw invalid() + const date = new Date(`${match[1]}T${match[2]}Z`) + if (!Number.isFinite(date.valueOf()) || date.toISOString().slice(0, 19) !== `${match[1]}T${match[2]}`) throw invalid() + const milliseconds = Date.parse(value) + if (!Number.isFinite(milliseconds)) throw invalid() + return milliseconds +} diff --git a/packages/app/src/cli/services/historical-logs/presenter.ts b/packages/app/src/cli/services/historical-logs/presenter.ts new file mode 100644 index 00000000000..88156b7cdf8 --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/presenter.ts @@ -0,0 +1,50 @@ +import {logsOutput, type LogsResult} from './types.js' +import {outputInfo, outputResult, unstyled} from '@shopify/cli-kit/node/output' +import {renderTable} from '@shopify/cli-kit/node/ui' + +export function renderHistoricalLogs(result: LogsResult, json: boolean): void { + if (json) { + outputResult(logsOutput.encode(result)) + return + } + const orderingLabel = { + 'subset-timestamp-asc': + 'Used fast retrieval: returned records shown oldest first, not necessarily the oldest matching records.', + 'timestamp-asc': 'Oldest first by timestamp.', + 'timestamp-desc': 'Newest first by timestamp.', + } + outputInfo(orderingLabel[result.ordering]) + if (!result.logs.length) { + outputInfo('No matching records returned.') + return + } + const showApi = result.logs.some((record) => record.type === 'GRAPHQL_REQUEST') + renderTable({ + rows: result.logs.map((record) => ({ + timestamp: terminalValue(record.timestamp), + type: terminalValue(record.type), + result: terminalValue(record.resultStatus), + store: terminalValue(record.shopMyshopifyDomain), + topicOrOperation: terminalValue(record.topic ?? record.operationName), + ...(showApi ? {api: record.type === 'GRAPHQL_REQUEST' ? terminalValue(record.api) : '—'} : {}), + })), + columns: { + timestamp: {header: 'Timestamp'}, + type: {header: 'Type'}, + result: {header: 'Result'}, + store: {header: 'Store'}, + topicOrOperation: {header: 'Topic / operation'}, + ...(showApi ? {api: {header: 'API'}} : {}), + }, + }) + outputInfo('Use --json for full records.') +} + +function terminalValue(value: unknown): string { + const text = typeof value === 'string' ? value : 'unknown' + const escaped = unstyled(text).replace( + /[\p{Cc}\p{Cf}]/gu, + (character) => `\\u${character.codePointAt(0)!.toString(16).padStart(4, '0')}`, + ) + return escaped.length > 100 ? `${escaped.slice(0, 99)}…` : escaped +} diff --git a/packages/app/src/cli/services/historical-logs/response.ts b/packages/app/src/cli/services/historical-logs/response.ts new file mode 100644 index 00000000000..508422e87e6 --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/response.ts @@ -0,0 +1,14 @@ +import {nativeLogRecord} from './types.js' +import {zod} from '@shopify/cli-kit/node/schema' + +export const historicalLogsSchema = zod.object({ + app: zod + .object({ + logs: zod + .object({ + records: zod.array(zod.object({json: nativeLogRecord})), + }) + .nullable(), + }) + .nullable(), +}) diff --git a/packages/app/src/cli/services/historical-logs/service.test.ts b/packages/app/src/cli/services/historical-logs/service.test.ts new file mode 100644 index 00000000000..8531d41755b --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/service.test.ts @@ -0,0 +1,557 @@ +import {retrieveLogs} from './service.js' +import {renderHistoricalLogs} from './presenter.js' +import {LogApp} from '../../api/graphql/app-management/generated/log-app.js' +import {appManagementRequestDoc} from '@shopify/cli-kit/node/api/app-management' +import {fetch, Response} from '@shopify/cli-kit/node/http' +import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session' +import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn' +import {getAppAutomationToken} from '@shopify/cli-kit/node/environment' +import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs' +import {joinPath} from '@shopify/cli-kit/node/path' +import {handler} from '@shopify/cli-kit/node/error' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {afterEach, beforeEach, expect, test, vi} from 'vitest' + +vi.mock('@shopify/cli-kit/node/http', async (importOriginal) => ({ + ...(await importOriginal()), + fetch: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/session') +vi.mock('@shopify/cli-kit/node/api/app-management', async (importOriginal) => ({ + ...(await importOriginal()), + appManagementRequestDoc: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/context/fqdn', async (importOriginal) => ({ + ...(await importOriginal()), + appManagementFqdn: vi.fn(), +})) +vi.mock('@shopify/cli-kit/node/environment', async (importOriginal) => ({ + ...(await importOriginal()), + getAppAutomationToken: vi.fn(), +})) + +const clientId = 'test-app' +const scope = {'client-id': clientId, 'no-input': true, type: ['webhook']} +const record = { + id: 'gid://shopify/AppLogRecord/example', + timestamp: '2026-10-06T13:30:00.123456789Z', + type: 'WEBHOOK_DELIVERY', + shopMyshopifyDomain: 'example.myshopify.com', + topic: 'orders/create', + resultStatus: 'FAILURE', + statusCode: 'No response from app', + responseTimeMs: 6000, + deliveryAttempt: 2, + payloadHmac: 'test-signature', + headers: [{name: 'X-Test', value: 'test-value'}], + shopId: 'gid://shopify/Shop/123', + futureField: {nested: [null, false, 'unchanged']}, +} + +beforeEach(() => { + vi.useFakeTimers({toFake: ['Date'], now: new Date('2026-10-06T14:00:00Z')}) + vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local') + vi.mocked(appManagementFqdn).mockResolvedValue('app.shop.dev') + vi.mocked(getAppAutomationToken).mockReturnValue(undefined) + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({ + appManagementToken: 'test-identity-token', + userId: 'test-user', + businessPlatformToken: 'test-business-token', + }) +}) +afterEach(() => { + vi.useRealTimers() + vi.unstubAllEnvs() +}) + +function respond(body: unknown, status = 200, headers = {}) { + vi.mocked(fetch).mockResolvedValueOnce(new Response(JSON.stringify(body), {status, headers})) +} +function authorize() { + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({ + app: {key: clientId, organizationId: 'gid://shopify/Organization/42'}, + }) +} +function logResponse(records: unknown[] = [record]) { + respond({data: {app: {logs: {records: records.map((json) => ({json}))}}}}) +} + +test('loads the client ID from the selected local configuration without prompting', async () => { + await inTemporaryDirectory(async (directory) => { + await writeFile(joinPath(directory, 'package.json'), '{}') + await writeFile(joinPath(directory, 'shopify.app.toml'), 'client_id = "other-app"') + await writeFile(joinPath(directory, 'shopify.app.test.toml'), `client_id = "${clientId}"`) + authorize() + logResponse() + + expect((await retrieveLogs({path: directory, config: 'test', 'no-input': true})).logs).toEqual([record]) + expect(appManagementRequestDoc).toHaveBeenCalledWith(expect.objectContaining({variables: {clientId}})) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).toHaveBeenCalledWith({noPrompt: true}) + }) +}) + +test('an explicit client ID takes precedence over local configuration', async () => { + await inTemporaryDirectory(async (directory) => { + await writeFile(joinPath(directory, 'package.json'), '{}') + await writeFile(joinPath(directory, 'shopify.app.toml'), 'client_id = "other-app"') + authorize() + logResponse() + + expect((await retrieveLogs({...scope, path: directory})).logs).toEqual([record]) + expect(appManagementRequestDoc).toHaveBeenCalledWith(expect.objectContaining({variables: {clientId}})) + }) +}) + +test.each(['client_id = ""', 'name = "unlinked-app"', 'client_id = ['])( + 'rejects unusable configuration %s before authentication', + async (content) => { + await inTemporaryDirectory(async (directory) => { + await writeFile(joinPath(directory, 'package.json'), '{}') + await writeFile(joinPath(directory, 'shopify.app.toml'), content) + + await expect(retrieveLogs({path: directory, 'no-input': true})).rejects.toThrow() + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() + }) + }, +) + +test('submits one bounded search and preserves the native record JSON exactly', async () => { + authorize() + logResponse() + const result = await retrieveLogs({...scope, status: 'failure', topic: 'orders/create'}) + expect(result.logs).toEqual([record]) + await withCapturedStandardStreams(async ({stdout}) => { + renderHistoricalLogs(result, true) + expect(JSON.parse(stdout()).logs).toEqual([record]) + expect(JSON.parse(stdout())).not.toHaveProperty('query') + expect(JSON.parse(stdout())).not.toHaveProperty('pageInfo') + expect(stdout()).not.toContain('test-identity-token') + }) + expect(result).toMatchObject({ + ordering: 'subset-timestamp-asc', + }) + expect(appManagementRequestDoc).toHaveBeenCalledExactlyOnceWith({ + query: LogApp, + token: 'test-identity-token', + variables: {clientId}, + unauthorizedHandler: {type: 'token_refresh', handler: expect.any(Function)}, + }) + const [url, init] = vi.mocked(fetch).mock.calls[0]! + expect(url).toBe('https://app.shop.dev/app_logs/unstable/organizations/42/graphql') + expect(init?.redirect).toBe('error') + expect(JSON.parse(String(init?.body)).variables).toEqual({ + clientId, + types: ['WEBHOOK_DELIVERY'], + timestamp: {gte: '2026-10-06T13:00:00Z', lt: '2026-10-06T14:00:00Z'}, + limit: 20, + filters: [ + {field: 'RESULT_STATUS', operator: 'EQUALS', value: 'FAILURE'}, + {field: 'TOPIC', operator: 'EQUALS', value: 'orders/create'}, + ], + }) + expect(fetch).toHaveBeenCalledTimes(1) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).toHaveBeenCalledWith({noPrompt: true}) +}) + +test.each([8, 20])('fast retrieval orders only the returned subset locally with limit %i', async (limit) => { + const timestamps = [ + '2026-10-05T23:59:59.999999999Z', + '2026-10-06T13:30:00Z', + '2026-10-06T13:30:00.000000001Z', + '2026-10-06T13:30:00.1Z', + '2026-10-06T13:30:00.123456788Z', + '2026-10-06T13:30:00.123456789Z', + '2026-10-06T13:30:00.123456789Z', + '2026-10-06T13:30:01Z', + ] + const chronological = timestamps.map((timestamp, index) => ({ + ...record, + id: `gid://shopify/AppLogRecord/record-${index}`, + timestamp, + })) + authorize() + logResponse([ + chronological[7], + chronological[5], + chronological[3], + chronological[1], + chronological[6], + chronological[4], + chronological[0], + chronological[2], + ]) + + const result = await retrieveLogs({...scope, limit}) + + expect(result.logs).toEqual(chronological) + expect(result.ordering).toBe('subset-timestamp-asc') + const variables = JSON.parse(String(vi.mocked(fetch).mock.calls[0]?.[1]?.body)).variables + expect(variables.limit).toBe(limit) + expect(variables).not.toHaveProperty('sortKey') + expect(variables).not.toHaveProperty('reverse') + expect(fetch).toHaveBeenCalledTimes(1) + await withCapturedStandardStreams(async ({stdout}) => { + renderHistoricalLogs(result, true) + expect(JSON.parse(stdout()).logs).toEqual(chronological) + expect(JSON.parse(stdout()).ordering).toBe('subset-timestamp-asc') + }) +}) + +test.each([ + {order: undefined, reverse: false, ordering: 'timestamp-asc'}, + {order: 'asc', reverse: false, ordering: 'timestamp-asc'}, + {order: 'desc', reverse: true, ordering: 'timestamp-desc'}, +])('requests server-side $ordering and preserves native records', async ({order, reverse, ordering}) => { + const laterRecord = {...record, id: 'gid://shopify/AppLogRecord/later', timestamp: '2026-10-06T13:31:00Z'} + const records = reverse ? [laterRecord, record] : [record, laterRecord] + authorize() + logResponse(records) + + const result = await retrieveLogs({...scope, sort: 'timestamp', order, limit: 2}) + + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0]?.[1]?.body)).variables).toMatchObject({ + sortKey: 'TIMESTAMP', + reverse, + limit: 2, + }) + expect(result.logs).toEqual(records) + expect(result.ordering).toBe(ordering) + expect(result.limitations).toEqual([]) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test.each(['INVALID_LOG_QUERY', 'THROTTLED'])( + 'surfaces sorted-query %s errors without retrying unordered', + async (code) => { + authorize() + const errors = [{message: 'Sorted search refused', extensions: {code}}] + respond({errors}) + + await expect(retrieveLogs({...scope, sort: 'timestamp', order: 'desc'})).rejects.toMatchObject({ + code: 'LOG_QUERY_FAILED', + details: {errors}, + }) + expect(fetch).toHaveBeenCalledTimes(1) + }, +) + +test.each(['Admin', 'Storefront', 'CustomerAccount'])( + 'preserves %s GraphQL diagnostics, bodies, and truncation flags', + async (target) => { + const graphqlRecord = { + id: record.id, + timestamp: record.timestamp, + type: 'GRAPHQL_REQUEST', + resultStatus: 'FAILURE', + shopMyshopifyDomain: null, + api: target, + operationName: 'MyQuery', + requestId: 'request-123', + requestBody: `query MyQuery { ${'field '.repeat(1000)}`, + responseBody: '{"errors":[{"message":"example"}]}', + requestBodyTruncated: true, + responseBodyTruncated: false, + errorCodes: ['EXAMPLE'], + futureDiagnostic: {present: true}, + } + authorize() + logResponse([graphqlRecord]) + const result = await retrieveLogs({...scope, type: ['graphql']}) + await withCapturedStandardStreams(async ({stdout}) => { + renderHistoricalLogs(result, true) + expect(JSON.parse(stdout()).logs).toEqual([graphqlRecord]) + }) + }, +) + +test('an empty successful search remains empty without asserting completeness', async () => { + authorize() + logResponse([]) + const result = await retrieveLogs(scope) + expect(result.logs).toEqual([]) + expect(result).not.toHaveProperty('pageInfo') + expect(result.limitations).toEqual([ + 'Used fast retrieval: returned records sorted oldest first in the CLI, not necessarily the oldest or newest matches. Additional records may match your search.', + ]) +}) + +test('default mixed-type search shares one query and limit, preserving every predicate', async () => { + authorize() + const graphqlRecord = {...record, type: 'GRAPHQL_REQUEST', api: 'storefront', timestamp: '2026-10-06T13:29:00Z'} + logResponse([record, graphqlRecord]) + const result = await retrieveLogs({'client-id': clientId, limit: 2}) + expect(result.logs).toEqual([graphqlRecord, record]) + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0]?.[1]?.body)).variables).toMatchObject({ + types: ['WEBHOOK_DELIVERY', 'GRAPHQL_REQUEST'], + limit: 2, + filters: [], + }) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test('cross-type filters are sent together and an empty intersection is not an error', async () => { + authorize() + logResponse([]) + const result = await retrieveLogs({'client-id': clientId, topic: 'orders/create', 'operation-name': 'MyQuery'}) + expect(result.logs).toEqual([]) + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0]?.[1]?.body)).variables).toMatchObject({ + types: ['WEBHOOK_DELIVERY', 'GRAPHQL_REQUEST'], + filters: [ + {field: 'TOPIC', operator: 'EQUALS', value: 'orders/create'}, + {field: 'OPERATION_NAME', operator: 'EQUALS', value: 'MyQuery'}, + ], + }) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test('a full cap preserves records without inventing pagination metadata', async () => { + authorize() + logResponse() + const result = await retrieveLogs({...scope, limit: 1}) + expect(result.logs).toEqual([record]) + expect(result).not.toHaveProperty('pageInfo') + expect(result.limitations).toEqual([ + 'Used fast retrieval: returned records sorted oldest first in the CLI, not necessarily the oldest or newest matches. Additional records may match your search.', + ]) +}) + +test('GraphQL errors preserve native errors, extensions, and partial data as a failure', async () => { + const data = {app: {logs: {records: [{json: record}]}}} + const errors = [{message: 'Timed out', path: ['app', 'logs'], extensions: {code: 'THROTTLED', retryAfter: 60}}] + const extensions = {requestId: 'test-request'} + authorize() + respond({data, errors, extensions}) + const failure = await retrieveLogs(scope).catch((error: unknown) => error) + expect(failure).toMatchObject({code: 'LOG_QUERY_FAILED', details: {data, errors, extensions}}) + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await handler(failure) + expect(JSON.parse(stdout())).toEqual({ + error: {type: 'abort', message: 'Timed out', code: 'LOG_QUERY_FAILED', details: {data, errors, extensions}}, + }) + expect(stderr()).toBe('') + }) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test.each(['APP_NOT_ACCESSIBLE', 'THROTTLED', 'INVALID_LOG_QUERY'])( + 'does not turn %s into empty success', + async (code) => { + authorize() + respond({data: {app: null}, errors: [{message: code, extensions: {code}}]}) + await expect(retrieveLogs(scope)).rejects.toMatchObject({ + code: 'LOG_QUERY_FAILED', + details: {errors: [{extensions: {code}}], data: {app: null}}, + }) + expect(fetch).toHaveBeenCalledTimes(1) + }, +) + +test.each([null, {}, {app: null}, {app: {logs: null}}, {app: {logs: {records: null}}}])( + 'rejects invalid log results %j', + async (data) => { + authorize() + respond({data}) + await expect(retrieveLogs(scope)).rejects.toMatchObject({code: 'INVALID_RESPONSE'}) + }, +) + +test.each([ + null, + 'not an object', + {...record, id: 42}, + {...record, timestamp: null}, + {...record, timestamp: 'not-a-timestamp'}, + {...record, timestamp: '2026-10-06T14:30:00+01:00'}, + {...record, type: 'GRAPHQL_REQUEST'}, +])('rejects invalid or unselected records %j', async (json) => { + authorize() + logResponse([json]) + await expect(retrieveLogs(scope)).rejects.toMatchObject({code: 'INVALID_RESPONSE'}) +}) + +test('rejects a response exceeding the requested cap rather than truncating it locally', async () => { + authorize() + logResponse([record, {...record, id: 'gid://shopify/AppLogRecord/second'}]) + await expect(retrieveLogs({...scope, limit: 1})).rejects.toMatchObject({code: 'INVALID_RESPONSE'}) +}) + +test.each([ + [401, 'AUTHENTICATION_REQUIRED'], + [403, 'FORBIDDEN'], + [429, 'RATE_LIMITED'], + [504, 'QUERY_TIMEOUT'], + [500, 'LOGS_API_UNAVAILABLE'], +] as const)('propagates HTTP %s as %s without retries', async (status, code) => { + authorize() + respond({}, status, {'retry-after': '60'}) + await expect(retrieveLogs(scope)).rejects.toMatchObject({code, details: {retryAfterSeconds: 60}}) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test.each(['app.shopify.com', 'app.shopify.io', 'app.shop.dev'])( + 'uses the standard App Management host %s for logs', + async (host) => { + vi.mocked(appManagementFqdn).mockResolvedValue(host) + authorize() + logResponse() + expect((await retrieveLogs(scope)).logs).toEqual([record]) + expect(fetch).toHaveBeenCalledExactlyOnceWith( + `https://${host}/app_logs/unstable/organizations/42/graphql`, + expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer test-identity-token'})}), + ) + }, +) + +test('rejects automation credentials instead of treating them as a user session', async () => { + vi.mocked(getAppAutomationToken).mockReturnValue('test-automation-token') + await expect(retrieveLogs(scope)).rejects.toMatchObject({code: 'AUTHENTICATION_REQUIRED'}) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() +}) + +test('does not turn unreadable JSON into an empty result', async () => { + authorize() + vi.mocked(fetch).mockResolvedValueOnce(new Response('upstream unavailable', {status: 200})) + await expect(retrieveLogs(scope)).rejects.toMatchObject({code: 'INVALID_RESPONSE'}) +}) + +test('does not attempt network access when no-input authentication fails', async () => { + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockRejectedValue(new Error('Sign in first')) + await expect(retrieveLogs(scope)).rejects.toThrow('Sign in first') + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).toHaveBeenCalledWith({noPrompt: true}) + expect(fetch).not.toHaveBeenCalled() +}) + +test.each([ + {since: 'bad'}, + {since: '0h'}, + {since: '2026-10-06T14:00:00Z', until: '2026-10-06T13:00:00Z'}, + {until: '2026-10-06T14:00:00.123Z'}, + {limit: 0}, + {'client-id': ''}, + {'operation-name': 'x'.repeat(1025)}, +])('rejects invalid local input before auth %j', async (input) => { + await expect(retrieveLogs({...scope, ...input})).rejects.toMatchObject({code: 'INVALID_ARGUMENT'}) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) + +test('reports transport timeouts without returning an empty result', async () => { + authorize() + vi.mocked(fetch).mockRejectedValueOnce(Object.assign(new Error('request aborted'), {name: 'TimeoutError'})) + await expect(retrieveLogs(scope)).rejects.toMatchObject({code: 'QUERY_TIMEOUT'}) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test.each(['AbortError', 'TimeoutError'])('reports a body-read %s as a timeout without retrying', async (name) => { + authorize() + const response = new Response(null, {status: 200}) + vi.spyOn(response, 'json').mockRejectedValueOnce(Object.assign(new Error('request aborted'), {name})) + vi.mocked(fetch).mockResolvedValueOnce(response) + + await expect(retrieveLogs(scope)).rejects.toMatchObject({code: 'QUERY_TIMEOUT'}) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test.each(['42', 'gid://shopify/App/42', 'gid://shopify/Organization/42/extra'])( + 'rejects invalid organization identifiers %s', + async (organizationId) => { + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({app: {key: clientId, organizationId}}) + await expect(retrieveLogs(scope)).rejects.toMatchObject({code: 'FORBIDDEN'}) + expect(fetch).not.toHaveBeenCalled() + }, +) + +test('rejects an app lookup for another client ID without querying logs', async () => { + vi.mocked(appManagementRequestDoc).mockResolvedValueOnce({ + app: {key: 'other-app', organizationId: 'gid://shopify/Organization/42'}, + }) + await expect(retrieveLogs(scope)).rejects.toMatchObject({code: 'FORBIDDEN'}) + expect(fetch).not.toHaveBeenCalled() +}) + +test('uses the local clock after app lookup', async () => { + vi.mocked(appManagementRequestDoc).mockImplementationOnce(async () => { + vi.setSystemTime(new Date('2026-10-06T14:02:03.987Z')) + return {app: {key: clientId, organizationId: 'gid://shopify/Organization/42'}} + }) + logResponse() + const result = await retrieveLogs(scope) + expect(result.logs).toEqual([record]) + expect(result.limitations.join(' ')).not.toContain('HTTP Date') + expect(JSON.parse(String(vi.mocked(fetch).mock.calls[0]?.[1]?.body)).variables.timestamp).toEqual({ + gte: '2026-10-06T13:02:03Z', + lt: '2026-10-06T14:02:03Z', + }) +}) + +test('uses a token refreshed during app lookup for the log request', async () => { + vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValueOnce({ + appManagementToken: 'expired-token', + businessPlatformToken: 'test-business-token', + userId: 'test-user', + }) + vi.mocked(appManagementRequestDoc).mockImplementationOnce(async ({unauthorizedHandler}) => { + await expect(unauthorizedHandler.handler()).resolves.toEqual({token: 'test-identity-token'}) + return {app: {key: clientId, organizationId: 'gid://shopify/Organization/42'}} + }) + logResponse() + expect((await retrieveLogs(scope)).logs).toEqual([record]) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).toHaveBeenLastCalledWith({ + noPrompt: true, + forceRefresh: true, + }) + expect(vi.mocked(fetch).mock.calls[0]?.[1]?.headers).toMatchObject({authorization: 'Bearer test-identity-token'}) +}) + +test('propagates app lookup failures without querying logs', async () => { + const failure = new Error('App lookup denied') + vi.mocked(appManagementRequestDoc).mockRejectedValueOnce(failure) + await expect(retrieveLogs(scope)).rejects.toBe(failure) + expect(fetch).not.toHaveBeenCalled() +}) + +test.each([ + [{since: '8d'}, 'The timestamp range must not exceed 7 days.'], + [{since: '2026-09-01T12:00:00Z', until: '2026-09-01T13:00:00Z'}, 'timestamp.gte must be within the last 30 days.'], + [{until: '2026-10-06T14:02:00Z'}, 'timestamp.lt must not be more than 1 minute in the future.'], +])('surfaces backend time-policy errors for %j without retrying', async (input, message) => { + authorize() + const errors = [{message, path: ['app', 'logs'], extensions: {code: 'INVALID_LOG_QUERY'}}] + respond({errors}) + const failure = await retrieveLogs({...scope, ...input}).catch((error: unknown) => error) + expect(failure).toMatchObject({message, code: 'LOG_QUERY_FAILED', details: {errors}}) + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await handler(failure) + expect(stdout()).toBe('') + expect(stderr()).toContain(message) + }) + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await handler(failure) + expect(JSON.parse(stdout())).toEqual({ + error: {type: 'abort', message, code: 'LOG_QUERY_FAILED', details: {errors}}, + }) + expect(stderr()).toBe('') + }) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test('renders CLI-owned validation codes at error.code without upstream details', async () => { + const failure = await retrieveLogs({...scope, since: '0h'}).catch((error: unknown) => error) + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await handler(failure) + expect(JSON.parse(stdout())).toEqual({ + error: { + type: 'abort', + message: '--since requires a positive integer duration or a whole-second timestamp.', + code: 'INVALID_ARGUMENT', + }, + }) + expect(stderr()).toBe('') + }) + expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled() + expect(fetch).not.toHaveBeenCalled() +}) diff --git a/packages/app/src/cli/services/historical-logs/service.ts b/packages/app/src/cli/services/historical-logs/service.ts new file mode 100644 index 00000000000..a6e9b8e8fed --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/service.ts @@ -0,0 +1,78 @@ +import {logError} from './catalog.js' +import {connectLogs, failOnErrors, logsRequest} from './api.js' +import {prepareLogInput, resolveLogWindow, type LogInput} from './input.js' +import {historicalLogsSchema} from './response.js' +import {logsOutput, type LogsResult} from './types.js' +import {getAppConfigurationContext} from '../../models/app/loader.js' +import {HistoricalLogs} from '../../api/graphql/app-logs/generated/historical-logs.js' +import {cwd} from '@shopify/cli-kit/node/path' + +interface LogScopeOptions { + 'client-id'?: string + path?: string + config?: string + 'no-input'?: boolean +} + +export async function retrieveLogs(options: LogScopeOptions & LogInput): Promise { + const prepared = prepareLogInput(options) + const clientId = await resolveLogApp(options) + const connection = await connectLogs(clientId, options['no-input'] ?? false) + const window = resolveLogWindow(prepared.window) + const result = await logsRequest(connection, HistoricalLogs, { + clientId, + types: prepared.types.map(({backend}) => backend), + timestamp: {gte: window.since, lt: window.until}, + limit: prepared.limit, + filters: prepared.filters, + ...(prepared.ordering === 'none' ? {} : {sortKey: 'TIMESTAMP', reverse: prepared.ordering === 'timestamp-desc'}), + }) + failOnErrors(result) + const response = historicalLogsSchema.safeParse(result.data) + if (!response.success || !response.data.app?.logs) + throw logError('INVALID_RESPONSE', 'App Logs returned no valid log result.') + const records = response.data.app.logs.records.map(({json}) => json) + if ( + records.length > prepared.limit || + records.some((record) => !prepared.types.some(({backend}) => backend === record.type)) + ) + throw logError('INVALID_RESPONSE', 'App Logs returned records outside the requested type or limit.') + + if (prepared.ordering === 'none') { + records.sort((left, right) => { + // Drop UTC's Z suffix so whole seconds sort before their fractional seconds, without losing precision. + const leftTimestamp = left.timestamp.slice(0, -1) + const rightTimestamp = right.timestamp.slice(0, -1) + if (leftTimestamp < rightTimestamp) return -1 + if (leftTimestamp > rightTimestamp) return 1 + return 0 + }) + } + + return logsOutput.validate({ + logs: records, + ordering: prepared.ordering === 'none' ? 'subset-timestamp-asc' : prepared.ordering, + limitations: + prepared.ordering === 'none' + ? [ + 'Used fast retrieval: returned records sorted oldest first in the CLI, not necessarily the oldest or newest matches. Additional records may match your search.', + ] + : [], + }) +} + +async function resolveLogApp(options: LogScopeOptions): Promise { + if (options['client-id'] !== undefined) { + if (!options['client-id'].trim()) throw logError('INVALID_ARGUMENT', '--client-id must not be empty.') + return options['client-id'] + } + const {activeConfig} = await getAppConfigurationContext(options.path ?? cwd(), options.config, { + skipPrompts: options['no-input'] ?? false, + }) + if (activeConfig.file.errors.length) + throw logError('INVALID_ARGUMENT', activeConfig.file.errors.map(({message}) => message).join('\n')) + const clientId = activeConfig.file.content.client_id + if (typeof clientId !== 'string' || !clientId.trim()) + throw logError('INVALID_ARGUMENT', 'Pass --client-id or select an app configuration containing client_id.') + return clientId +} diff --git a/packages/app/src/cli/services/historical-logs/types.ts b/packages/app/src/cli/services/historical-logs/types.ts new file mode 100644 index 00000000000..2defbaeb04b --- /dev/null +++ b/packages/app/src/cli/services/historical-logs/types.ts @@ -0,0 +1,33 @@ +import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' +import {zod} from '@shopify/cli-kit/node/schema' + +export const nativeLogRecord = zod + .object({ + id: zod.string().startsWith('gid://shopify/AppLogRecord/'), + timestamp: zod.string().datetime().describe('Native API timestamp; fractional seconds are preserved.'), + type: zod.string().describe('Native API enum, such as WEBHOOK_DELIVERY or GRAPHQL_REQUEST.'), + resultStatus: zod.string().nullable(), + shopMyshopifyDomain: zod.string().nullable(), + }) + .passthrough() + .describe( + 'Native record JSON. All additional approved API fields are preserved without renaming or truncation; this is not a deep per-type schema.', + ) + +export const logsOutput = defineJsonOutputSchema({ + name: 'HistoricalAppLogs', + schema: zod + .object({ + logs: zod.array(nativeLogRecord), + ordering: zod + .enum(['subset-timestamp-asc', 'timestamp-asc', 'timestamp-desc']) + .describe( + 'subset-timestamp-asc sorts only the returned subset in the CLI, not necessarily the oldest or newest matches. timestamp-asc and timestamp-desc request server sorting before the limit.', + ), + limitations: zod.array(zod.string()), + }) + .strict(), + definitions: {AppLogRecord: nativeLogRecord}, +}) + +export type LogsResult = InferJsonOutputSchema diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 68428dc023d..3ad4ab43952 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -3933,6 +3933,231 @@ "strict": true, "summary": "Stream detailed logs for your Shopify app." }, + "app:logs-next": { + "aliases": [ + ], + "args": { + }, + "customPluginName": "@shopify/app", + "description": "Returns a list of historical app logs and exits. Defaults to all supported log types. Use --type to select a type; repeat it to select multiple.\n\nFast retrieval is the default: leave out --sort. Prefer this mode with a narrow time window and selective filters to consume less throttle capacity. The CLI orders the returned records oldest first, but they aren't necessarily the oldest or newest matches.\n\nUse server-side sorting only when selecting the oldest or newest matching records matters: --sort timestamp selects oldest first; add --order desc for newest first. This sorts before the result limit and is subject to API query limits.\n\nDifferent filters combine with AND; each accepts one exact value. Type-specific filters exclude records without that field; combining --topic and --operation-name returns no records.\n\nDefaults: preceding hour, 20 records. Maximum: seven-day window within thirty days, 1000 records. Time inputs accept whole seconds only.\n\n--json preserves native record JSON in logs[], including fractional timestamps, diagnostic fields, bodies, and producer truncation flags.\n\nUse `--json-schema` to print the result, error, and event schemas.\n\nOutput from `--json` conforms to the `HistoricalAppLogs` schema.\n\n```json\n{\n \"type\": \"object\",\n \"properties\": {\n \"logs\": {\n \"type\": \"array\",\n \"items\": {\n \"$ref\": \"#/definitions/AppLogRecord\"\n }\n },\n \"ordering\": {\n \"type\": \"string\",\n \"enum\": [\n \"subset-timestamp-asc\",\n \"timestamp-asc\",\n \"timestamp-desc\"\n ],\n \"description\": \"subset-timestamp-asc sorts only the returned subset in the CLI, not necessarily the oldest or newest matches. timestamp-asc and timestamp-desc request server sorting before the limit.\"\n },\n \"limitations\": {\n \"type\": \"array\",\n \"items\": {\n \"type\": \"string\"\n }\n }\n },\n \"required\": [\n \"logs\",\n \"ordering\",\n \"limitations\"\n ],\n \"additionalProperties\": false,\n \"title\": \"HistoricalAppLogs\",\n \"definitions\": {\n \"AppLogRecord\": {\n \"type\": \"object\",\n \"properties\": {\n \"id\": {\n \"type\": \"string\",\n \"pattern\": \"^gid\\\\:\\\\/\\\\/shopify\\\\/AppLogRecord\\\\/\"\n },\n \"timestamp\": {\n \"type\": \"string\",\n \"format\": \"date-time\",\n \"description\": \"Native API timestamp; fractional seconds are preserved.\"\n },\n \"type\": {\n \"type\": \"string\",\n \"description\": \"Native API enum, such as WEBHOOK_DELIVERY or GRAPHQL_REQUEST.\"\n },\n \"resultStatus\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n },\n \"shopMyshopifyDomain\": {\n \"type\": [\n \"string\",\n \"null\"\n ]\n }\n },\n \"required\": [\n \"id\",\n \"timestamp\",\n \"type\",\n \"resultStatus\",\n \"shopMyshopifyDomain\"\n ],\n \"additionalProperties\": true,\n \"description\": \"Native record JSON. All additional approved API fields are preserved without renaming or truncation; this is not a deep per-type schema.\"\n }\n },\n \"$schema\": \"http://json-schema.org/draft-07/schema#\"\n}\n```", + "descriptionWithMarkdown": "Returns a list of historical app logs and exits. Defaults to all supported log types. Use --type to select a type; repeat it to select multiple.\n\nFast retrieval is the default: leave out --sort. Prefer this mode with a narrow time window and selective filters to consume less throttle capacity. The CLI orders the returned records oldest first, but they aren't necessarily the oldest or newest matches.\n\nUse server-side sorting only when selecting the oldest or newest matching records matters: --sort timestamp selects oldest first; add --order desc for newest first. This sorts before the result limit and is subject to API query limits.\n\nDifferent filters combine with AND; each accepts one exact value. Type-specific filters exclude records without that field; combining --topic and --operation-name returns no records.\n\nDefaults: preceding hour, 20 records. Maximum: seven-day window within thirty days, 1000 records. Time inputs accept whole seconds only.\n\n--json preserves native record JSON in logs[], including fractional timestamps, diagnostic fields, bodies, and producer truncation flags.", + "enableJsonFlag": false, + "examples": [ + "<%= config.bin %> <%= command.id %> --client-id APP_CLIENT_ID --type webhook --topic orders/create --status failure --since 30m", + "<%= config.bin %> <%= command.id %> --type graphql --operation-name MyQuery --json --no-input", + "<%= config.bin %> <%= command.id %> --since 1h --sort timestamp --order desc" + ], + "flags": { + "auth-alias": { + "description": "Alias of the Shopify account to use for authentication.", + "env": "SHOPIFY_FLAG_AUTH_ALIAS", + "hasDynamicHelp": false, + "multiple": false, + "name": "auth-alias", + "type": "option" + }, + "client-id": { + "description": "App client ID; takes precedence over local app configuration.", + "env": "SHOPIFY_FLAG_CLIENT_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "client-id", + "type": "option" + }, + "config": { + "char": "c", + "description": "The name of the app configuration.", + "env": "SHOPIFY_FLAG_APP_CONFIG", + "hasDynamicHelp": false, + "hidden": false, + "multiple": false, + "name": "config", + "type": "option" + }, + "json": { + "allowNo": false, + "char": "j", + "description": "Output the result as JSON. Automatically disables color output.", + "env": "SHOPIFY_FLAG_JSON", + "hidden": false, + "name": "json", + "type": "boolean" + }, + "json-schema": { + "allowNo": false, + "description": "Print the command's JSON schemas.", + "env": "SHOPIFY_FLAG_JSON_SCHEMA", + "name": "json-schema", + "type": "boolean" + }, + "limit": { + "default": 20, + "description": "Maximum records, from 1 through 1000; fast retrieval does not select the oldest or newest matches.", + "env": "SHOPIFY_FLAG_LIMIT", + "hasDynamicHelp": false, + "multiple": false, + "name": "limit", + "type": "option" + }, + "no-color": { + "allowNo": false, + "description": "Disable color output.", + "env": "SHOPIFY_FLAG_NO_COLOR", + "hidden": false, + "name": "no-color", + "type": "boolean" + }, + "no-input": { + "allowNo": false, + "description": "Disable interactive prompts and browser authentication.", + "env": "SHOPIFY_FLAG_NO_INPUT", + "name": "no-input", + "type": "boolean" + }, + "operation-name": { + "description": "Exact GraphQL operation name; excludes non-GraphQL records.", + "env": "SHOPIFY_FLAG_OPERATION_NAME", + "hasDynamicHelp": false, + "multiple": false, + "name": "operation-name", + "type": "option" + }, + "operation-type": { + "description": "Exact GraphQL operation type; excludes non-GraphQL records.", + "env": "SHOPIFY_FLAG_OPERATION_TYPE", + "hasDynamicHelp": false, + "multiple": false, + "name": "operation-type", + "type": "option" + }, + "order": { + "dependsOn": [ + "sort" + ], + "description": "Sort direction: asc (oldest first, default with --sort) or desc (newest first).", + "env": "SHOPIFY_FLAG_ORDER", + "hasDynamicHelp": false, + "multiple": false, + "name": "order", + "options": [ + "asc", + "desc" + ], + "type": "option" + }, + "path": { + "description": "Path to your app directory.", + "env": "SHOPIFY_FLAG_PATH", + "hasDynamicHelp": false, + "multiple": false, + "name": "path", + "type": "option" + }, + "request-id": { + "description": "Exact GraphQL request ID; excludes non-GraphQL records.", + "env": "SHOPIFY_FLAG_REQUEST_ID", + "hasDynamicHelp": false, + "multiple": false, + "name": "request-id", + "type": "option" + }, + "since": { + "default": "1h", + "description": "Inclusive start: positive duration (s/m/h/d) or whole-second RFC 3339 timestamp.", + "env": "SHOPIFY_FLAG_SINCE", + "hasDynamicHelp": false, + "multiple": false, + "name": "since", + "type": "option" + }, + "sort": { + "description": "Select the oldest/newest matches using server-side sorting. Omit for faster, less-throttled retrieval.", + "env": "SHOPIFY_FLAG_SORT", + "hasDynamicHelp": false, + "multiple": false, + "name": "sort", + "options": [ + "timestamp" + ], + "type": "option" + }, + "status": { + "description": "Recorded result status; not inferred from HTTP codes or response bodies.", + "env": "SHOPIFY_FLAG_STATUS", + "hasDynamicHelp": false, + "multiple": false, + "name": "status", + "options": [ + "success", + "failure" + ], + "type": "option" + }, + "status-code": { + "description": "Exact webhook HTTP status code (100–599); no status classes.", + "env": "SHOPIFY_FLAG_STATUS_CODE", + "hasDynamicHelp": false, + "multiple": false, + "name": "status-code", + "type": "option" + }, + "store": { + "char": "s", + "description": "Exact store handle or myshopify.com domain/URL.", + "env": "SHOPIFY_FLAG_STORE", + "hasDynamicHelp": false, + "multiple": false, + "name": "store", + "type": "option" + }, + "topic": { + "description": "Exact webhook topic; excludes non-webhook records.", + "env": "SHOPIFY_FLAG_TOPIC", + "hasDynamicHelp": false, + "multiple": false, + "name": "topic", + "type": "option" + }, + "type": { + "description": "Log type; repeat to select multiple. Defaults to all supported types.", + "env": "SHOPIFY_FLAG_TYPE", + "hasDynamicHelp": false, + "multiple": true, + "name": "type", + "options": [ + "webhook", + "graphql" + ], + "type": "option" + }, + "until": { + "description": "Exclusive whole-second RFC 3339 end; defaults to the current time.", + "env": "SHOPIFY_FLAG_UNTIL", + "hasDynamicHelp": false, + "multiple": false, + "name": "until", + "type": "option" + }, + "verbose": { + "allowNo": false, + "description": "Increase the verbosity of the output. May include sensitive data.", + "env": "SHOPIFY_FLAG_VERBOSE", + "hidden": false, + "name": "verbose", + "type": "boolean" + } + }, + "hasDynamicHelp": false, + "hidden": true, + "hiddenAliases": [ + ], + "id": "app:logs-next", + "pluginAlias": "@shopify/cli", + "pluginName": "@shopify/cli", + "pluginType": "core", + "strict": true, + "summary": "Lists historical app logs." + }, "app:logs:sources": { "aliases": [ ], diff --git a/packages/cli/src/cli/logs-next-help.test.ts b/packages/cli/src/cli/logs-next-help.test.ts new file mode 100644 index 00000000000..21e477d44d0 --- /dev/null +++ b/packages/cli/src/cli/logs-next-help.test.ts @@ -0,0 +1,49 @@ +import ShopifyHelp from './help.js' +import {helpService} from './services/commands/help/index.js' +import {Config, ux} from '@oclif/core' +import {expect, test, vi} from 'vitest' +import {fileURLToPath} from 'node:url' + +async function loadConfig() { + return Config.load({root: fileURLToPath(new URL('../../', import.meta.url)), userPlugins: false, devPlugins: false}) +} + +test('registers hidden retrieval alongside the existing streaming commands', async () => { + const config = await loadConfig() + const next = config.findCommand('app:logs-next')! + const logs = config.findCommand('app:logs')! + const sources = config.findCommand('app:logs:sources') + + expect(next.hidden).toBe(true) + expect(next.flags).toHaveProperty('since') + expect(next.flags).toHaveProperty('limit') + expect(next.flags).not.toHaveProperty('source') + expect(logs.flags).toHaveProperty('source') + expect(sources).toBeDefined() +}) + +test('normal app help omits the hidden retrieval command', async () => { + const config = await loadConfig() + const help = new ShopifyHelp(config, {stripAnsi: true}) + const log = vi.spyOn(ux, 'stdout').mockImplementation(() => {}) + + await help.showHelp(['app']) + const result = await helpService(await loadConfig(), ['app']) + + expect(log.mock.calls.flat().join('\n')).not.toContain('logs-next') + expect(result.commands.some(({id}) => id === 'app:logs-next')).toBe(false) + expect(result.topics.some(({name}) => name === 'app:logs-next')).toBe(false) + expect(result.commands).toContainEqual(expect.objectContaining({id: 'app:logs', hidden: false})) +}) + +test('direct text and JSON help remain available for the hidden command', async () => { + const config = await loadConfig() + const help = new ShopifyHelp(config, {stripAnsi: true}) + const log = vi.spyOn(ux, 'stdout').mockImplementation(() => {}) + + await help.showHelp(['app', 'logs-next']) + const result = await helpService(await loadConfig(), ['app', 'logs-next']) + + expect(log.mock.calls.flat().join('\n')).toContain('app logs-next') + expect(result).toMatchObject({kind: 'command', command: {id: 'app:logs-next', hidden: true}}) +})