diff --git a/.changeset/remove-app-logs.md b/.changeset/remove-app-logs.md new file mode 100644 index 00000000000..d78d731b1c1 --- /dev/null +++ b/.changeset/remove-app-logs.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': major +--- + +Remove `app logs` and `app logs sources`; use `app dev` to stream Function logs and save them to `.shopify/logs`. diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index bd1fca43978..f1a53fae7a4 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -3173,235 +3173,6 @@ "value": "export interface appinit {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app. Use this to automatically link your new project to an existing app. In non-interactive environments, provide this flag or both --name and --organization-id.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * Which flavor of the given template to use. Required in non-interactive environments when the selected template offers multiple flavors.\n * @environment SHOPIFY_FLAG_TEMPLATE_FLAVOR\n */\n '--flavor '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * The name for the new app. When provided, skips the app selection prompt and creates a new app with this name. Required in non-interactive environments unless --client-id is provided.\n * @environment SHOPIFY_FLAG_NAME\n */\n '-n, --name '?: string\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The organization ID. Your organization ID can be found in your Dev Dashboard URL: https://dev.shopify.com/dashboard/. Required in non-interactive environments unless --client-id is provided.\n * @environment SHOPIFY_FLAG_ORGANIZATION_ID\n */\n '--organization-id '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PACKAGE_MANAGER\n */\n '-d, --package-manager '?: string\n\n /**\n * \n * @environment SHOPIFY_FLAG_PATH\n */\n '-p, --path '?: string\n\n /**\n * The app template. Accepts one of the following:\n - \n - Any GitHub repo with optional branch and subpath, e.g., https://github.com/Shopify//[subpath]#[branch]. Required if non interactive.\n * @environment SHOPIFY_FLAG_TEMPLATE\n */\n '--template '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, - "applogssources": { - "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "name": "applogssources", - "description": "The following flags are available for the `app logs sources` command:", - "isPublicDocs": true, - "members": [ - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--auth-alias ", - "value": "string", - "description": "Alias of the Shopify account to use for authentication.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--client-id ", - "value": "string", - "description": "The Client ID of your app.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--json-schema", - "value": "''", - "description": "Print the command's JSON schemas.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-color", - "value": "''", - "description": "Disable color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_COLOR" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-input", - "value": "''", - "description": "Disable interactive prompts and browser authentication.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_INPUT" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--path ", - "value": "string", - "description": "The path to your app directory.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_PATH" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--reset", - "value": "''", - "description": "Reset all your settings.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_RESET" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--verbose", - "value": "''", - "description": "Increase the verbosity of the output. May include sensitive data.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_VERBOSE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-c, --config ", - "value": "string", - "description": "The name of the app configuration.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs-sources.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-j, --json", - "value": "''", - "description": "Output the result as JSON. Automatically disables color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON" - } - ], - "value": "export interface applogssources {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" - } - }, - "applogs": { - "docs-shopify.dev/commands/interfaces/app-logs.interface.ts": { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "name": "applogs", - "description": "The following flags are available for the `app logs` command:", - "isPublicDocs": true, - "members": [ - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--auth-alias ", - "value": "string", - "description": "Alias of the Shopify account to use for authentication.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_AUTH_ALIAS" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--client-id ", - "value": "string", - "description": "The Client ID of your app.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_CLIENT_ID" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--json-schema", - "value": "''", - "description": "Print the command's JSON schemas.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON_SCHEMA" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-color", - "value": "''", - "description": "Disable color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_COLOR" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--no-input", - "value": "''", - "description": "Disable interactive prompts and browser authentication.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_NO_INPUT" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--path ", - "value": "string", - "description": "The path to your app directory.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_PATH" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--reset", - "value": "''", - "description": "Reset all your settings.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_RESET" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--source ", - "value": "string", - "description": "Filters output to the specified log source.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_SOURCE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--status ", - "value": "string", - "description": "Filters output to the specified status (success or failure).", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_STATUS" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "--verbose", - "value": "''", - "description": "Increase the verbosity of the output. May include sensitive data.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_VERBOSE" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-c, --config ", - "value": "string", - "description": "The name of the app configuration.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_APP_CONFIG" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-j, --json", - "value": "''", - "description": "Output the result as JSON. Automatically disables color output.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_JSON" - }, - { - "filePath": "docs-shopify.dev/commands/interfaces/app-logs.interface.ts", - "syntaxKind": "PropertySignature", - "name": "-s, --store ", - "value": "string", - "description": "Store URL. Must be an existing development or Shopify Plus sandbox store.", - "isOptional": true, - "environmentValue": "SHOPIFY_FLAG_STORE" - } - ], - "value": "export interface applogs {\n /**\n * Alias of the Shopify account to use for authentication.\n * @environment SHOPIFY_FLAG_AUTH_ALIAS\n */\n '--auth-alias '?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id '?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config '?: string\n\n /**\n * Output the result as JSON. Automatically disables color output.\n * @environment SHOPIFY_FLAG_JSON\n */\n '-j, --json'?: ''\n\n /**\n * Print the command's JSON schemas.\n * @environment SHOPIFY_FLAG_JSON_SCHEMA\n */\n '--json-schema'?: ''\n\n /**\n * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * Disable interactive prompts and browser authentication.\n * @environment SHOPIFY_FLAG_NO_INPUT\n */\n '--no-input'?: ''\n\n /**\n * The path to your app directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Filters output to the specified log source.\n * @environment SHOPIFY_FLAG_SOURCE\n */\n '--source '?: string\n\n /**\n * Filters output to the specified status (success or failure).\n * @environment SHOPIFY_FLAG_STATUS\n */\n '--status '?: string\n\n /**\n * Store URL. Must be an existing development or Shopify Plus sandbox store.\n * @environment SHOPIFY_FLAG_STORE\n */\n '-s, --store '?: string\n\n /**\n * Increase the verbosity of the output. May include sensitive data.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" - } - }, "apprelease": { "docs-shopify.dev/commands/interfaces/app-release.interface.ts": { "filePath": "docs-shopify.dev/commands/interfaces/app-release.interface.ts", diff --git a/packages/app/src/cli/commands/app/app-logs/sources.test.ts b/packages/app/src/cli/commands/app/app-logs/sources.test.ts deleted file mode 100644 index 55fa6e63551..00000000000 --- a/packages/app/src/cli/commands/app/app-logs/sources.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -import {testAppLinked, testFunctionExtension} from '../../../models/app/app.test-data.js' -import {AppErrors} from '../../../models/app/loader.js' -import {Config} from '@oclif/core' -import {afterEach, expect, test, vi} from 'vitest' -import {formatSection} from '@shopify/cli-kit/node/output' -import {mockAndCaptureOutput} from '@shopify/cli-kit/node/testing/output' -// eslint-disable-next-line n/prefer-global/console -import {Console} from 'node:console' - -vi.mock('../../../services/app-context.js') - -const originalUnitTestEnvironment = process.env.SHOPIFY_UNIT_TEST - -afterEach(() => { - if (originalUnitTestEnvironment === undefined) { - delete process.env.SHOPIFY_UNIT_TEST - } else { - process.env.SHOPIFY_UNIT_TEST = originalUnitTestEnvironment - } - mockAndCaptureOutput().clear() - vi.resetModules() -}) - -// Captures the real standard streams so JSON and text output are proven at the process boundary. -function captureStandardStreams() { - const stdout: string[] = [] - const stderr: string[] = [] - - const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stdout.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) - return true - }) as typeof process.stdout.write) - const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array) => { - stderr.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8')) - return true - }) as typeof process.stderr.write) - // Vitest intercepts console.warn; use Node's console to exercise the captured streams. - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(new Console(process.stdout, process.stderr).warn) - - return { - stdout: () => stdout.join(''), - stderr: () => stderr.join(''), - restore: () => { - warnSpy.mockRestore() - stdoutSpy.mockRestore() - stderrSpy.mockRestore() - }, - } -} - -async function runCommand(argv: string[], app = testAppLinked()) { - const {linkedAppContext} = await import('../../../services/app-context.js') - vi.mocked(linkedAppContext).mockResolvedValue({app} as Awaited>) - const {default: Sources} = await import('./sources.js') - return new Sources(argv, await Config.load()).run() -} - -test('writes one JSON document to stdout without terminal text', async () => { - process.env.SHOPIFY_UNIT_TEST = 'false' - vi.resetModules() - const app = testAppLinked({ - allExtensions: [ - await testFunctionExtension({config: {...(await testFunctionExtension()).configuration, handle: 'discount'}}), - ], - }) - const streams = captureStandardStreams() - try { - await expect(runCommand(['--json'], app)).resolves.toEqual({app}) - } finally { - streams.restore() - } - const extension = app.allExtensions[0]! - expect(JSON.parse(streams.stdout())).toEqual({ - sources: [ - { - source: 'extensions.discount', - namespace: 'extensions', - handle: 'discount', - name: extension.configuration.name, - type: extension.type, - externalType: extension.externalType, - humanName: extension.humanName, - uid: extension.uid, - directory: extension.directory, - configurationPath: extension.configurationPath, - configuration: extension.configuration, - entrySourceFilePath: extension.entrySourceFilePath, - outputPath: extension.outputPath, - surface: extension.surface, - features: extension.features, - ...(extension.dependency === undefined ? {} : {dependency: extension.dependency}), - }, - ], - }) - expect(streams.stderr()).toBe('') -}) - -test('writes a named empty JSON collection when no sources exist', async () => { - process.env.SHOPIFY_UNIT_TEST = 'false' - vi.resetModules() - const streams = captureStandardStreams() - try { - await runCommand(['--json']) - } finally { - streams.restore() - } - expect(JSON.parse(streams.stdout())).toEqual({sources: []}) - expect(streams.stderr()).toBe('') -}) - -test('keeps namespace sections on stdout in text mode', async () => { - process.env.SHOPIFY_UNIT_TEST = 'false' - vi.resetModules() - const app = testAppLinked({ - allExtensions: [ - await testFunctionExtension({config: {...(await testFunctionExtension()).configuration, handle: 'discount'}}), - ], - }) - const streams = captureStandardStreams() - try { - await runCommand([], app) - } finally { - streams.restore() - } - expect(streams.stdout()).toBe(`${formatSection('extensions', 'extensions.discount')}\n`) - expect(streams.stderr()).toBe('') -}) - -test.each([{argv: []}, {argv: ['--json']}])( - 'exits with status 2 before output for invalid apps (%j)', - async ({argv}) => { - const errors = new AppErrors() - errors.addError({file: 'shopify.app.toml', message: 'Invalid app'}) - const exit = vi.spyOn(process, 'exit').mockImplementation(() => { - throw new Error('exit') - }) - try { - await expect(runCommand(argv, testAppLinked({errors}))).rejects.toThrow('exit') - expect(exit).toHaveBeenCalledWith(2) - expect(mockAndCaptureOutput().output()).toBe('') - } finally { - exit.mockRestore() - } - }, -) - -test('exposes the schema in help', async () => { - const {default: Sources} = await import('./sources.js') - const {appLogSourcesJsonOutputSchema} = await import('../../../services/app-logs/sources/types.js') - expect(Sources.jsonOutputSchema).toBe(appLogSourcesJsonOutputSchema) - expect(Sources.descriptionForHelp()).toContain('`AppLogSourcesResult` schema') -}) diff --git a/packages/app/src/cli/commands/app/app-logs/sources.ts b/packages/app/src/cli/commands/app/app-logs/sources.ts deleted file mode 100644 index 0d04ee8864b..00000000000 --- a/packages/app/src/cli/commands/app/app-logs/sources.ts +++ /dev/null @@ -1,43 +0,0 @@ -import {appFlags} from '../../../flags.js' -import {linkedAppContext} from '../../../services/app-context.js' -import {sources} from '../../../services/app-logs/sources.js' -import {appLogSourcesJsonOutputSchema} from '../../../services/app-logs/sources/types.js' -import {renderAppLogSourcesResult} from '../../../services/app-logs/sources/result.js' -import AppLinkedCommand, {AppLinkedCommandOutput} from '../../../utilities/app-linked-command.js' -import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' - -export default class Sources extends AppLinkedCommand { - static summary = 'Print out a list of sources that may be used with the logs command.' - - static descriptionWithMarkdown = `The output source names can be used with the \`--source\` argument of \`shopify app logs\` to filter log output. Currently only function extensions are supported as sources.` - - static get jsonOutputSchema() { - return appLogSourcesJsonOutputSchema - } - - static description = this.descriptionForHelp() - - static flags = { - ...globalFlags, - ...appFlags, - ...jsonFlag, - } - - public async run(): Promise { - const {flags} = await this.parse(Sources) - - const {app} = await linkedAppContext({ - directory: flags.path, - clientId: flags['client-id'], - forceRelink: flags.reset, - userProvidedConfigName: flags.config, - }) - - if (app.errors.isEmpty()) { - renderAppLogSourcesResult(sources(app), flags.json ? 'json' : 'text') - } else { - process.exit(2) - } - return {app} - } -} diff --git a/packages/app/src/cli/commands/app/logs.test.ts b/packages/app/src/cli/commands/app/logs.test.ts new file mode 100644 index 00000000000..fbdd314238d --- /dev/null +++ b/packages/app/src/cli/commands/app/logs.test.ts @@ -0,0 +1,75 @@ +import Logs from './logs.js' +import {Config} from '@oclif/core' +import {reportAnalyticsEvent} from '@shopify/cli-kit/node/analytics' +import {unstyled} from '@shopify/cli-kit/node/output' +import {withCapturedStandardStreams} from '@shopify/cli-kit/node/testing/output' +import {afterEach, describe, expect, test, vi} from 'vitest' +import {fileURLToPath} from 'node:url' + +vi.mock('@shopify/cli-kit/node/analytics') + +const removalMessage = + '`shopify app logs` and `shopify app logs sources` were removed in Shopify CLI 5.0. ' + + 'To see Function logs, run `shopify app dev`.' + +const originalExitCode = process.exitCode +afterEach(() => { + process.exitCode = originalExitCode + vi.unstubAllEnvs() +}) + +describe('removed app logs command', () => { + test.each( + [ + [], + ['--store', 'example.myshopify.com', '--status', 'failure'], + ['-s', 'first.myshopify.com', '-s', 'second.myshopify.com', '--source', 'extensions.discount'], + ['--path', '/missing/app', '--config', 'missing', '--client-id', 'missing', '--reset'], + ['sources'], + ['sources', '--path', '/missing/app'], + ].map((argv) => ({argv})), + )('returns migration guidance without loading an app for $argv', async ({argv}) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runLogs(argv) + expect(stdout()).toBe('') + expect( + unstyled(stderr()) + .replace(/[│╭╮╰╯─]/g, ' ') + .replace(/\s+/g, ' '), + ).toContain(removalMessage) + expect(reportAnalyticsEvent).toHaveBeenCalledWith(expect.objectContaining({exitMode: 'expected_error'})) + }) + }) + + test.each([[], ['sources'], ['--store', 'example.myshopify.com', '--status', 'failure']].map((argv) => ({argv})))( + 'writes one JSON error document for $argv', + async ({argv}) => { + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runLogs([...argv, '--json']) + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', message: removalMessage}}) + expect(stderr()).toBe('') + }) + }, + ) + + test('honors JSON output selected through the environment', async () => { + vi.stubEnv('SHOPIFY_FLAG_JSON', '1') + await withCapturedStandardStreams(async ({stdout, stderr}) => { + await runLogs([]) + expect(JSON.parse(stdout())).toMatchObject({error: {type: 'abort', message: removalMessage}}) + expect(stderr()).toBe('') + }) + }) +}) + +async function runLogs(argv: string[]) { + const config = await Config.load({root: fileURLToPath(new URL('../../../../', import.meta.url))}) + config.plugins.delete('@shopify/app') + const exit = vi.spyOn(process, 'exit').mockReturnValue(undefined as never) + try { + await Logs.run(argv, config) + expect(exit).toHaveBeenCalledExactlyOnceWith(1) + } finally { + exit.mockRestore() + } +} diff --git a/packages/app/src/cli/commands/app/logs.ts b/packages/app/src/cli/commands/app/logs.ts index 54539ec1275..d5c4000dd49 100644 --- a/packages/app/src/cli/commands/app/logs.ts +++ b/packages/app/src/cli/commands/app/logs.ts @@ -1,78 +1,21 @@ -import {checkFolderIsValidApp} from '../../models/app/loader.js' -import {logs, Format} from '../../services/logs.js' -import {appFlags} from '../../flags.js' -import AppLinkedCommand, {AppLinkedCommandOutput} from '../../utilities/app-linked-command.js' -import {linkedAppContext} from '../../services/app-context.js' -import {storeContext} from '../../services/store-context.js' -import {Flags} from '@oclif/core' -import {normalizeStoreFqdn} from '@shopify/cli-kit/node/context/fqdn' +import Command from '@shopify/cli-kit/node/base-command' import {globalFlags, jsonFlag} from '@shopify/cli-kit/node/cli' +import {AbortError} from '@shopify/cli-kit/node/error' -export default class Logs extends AppLinkedCommand { - static summary = 'Stream detailed logs for your Shopify app.' +export default class Logs extends Command { + static hidden = true - static descriptionWithMarkdown = ` - Opens a real-time stream of detailed app logs from the selected app and store. - Use the \`--source\` argument to limit output to a particular log source, such as a specific Shopify Function handle. Use the \`shopify app logs sources\` command to view a list of sources. - Use the \`--status\` argument to filter on status, either \`success\` or \`failure\`. - \`\`\` - shopify app logs --status=success --source=extension.discount-function - \`\`\` - ` + static strict = false - static description = this.descriptionForHelp() + static summary = 'App logs were removed in Shopify CLI 5.0.' - static flags = { - ...globalFlags, - ...appFlags, - ...jsonFlag, - store: Flags.string({ - char: 's', - description: 'Store URL. Must be an existing development or Shopify Plus sandbox store.', - env: 'SHOPIFY_FLAG_STORE', - multiple: true, - parse: async (input) => normalizeStoreFqdn(input), - }), - source: Flags.string({ - description: 'Filters output to the specified log source.', - env: 'SHOPIFY_FLAG_SOURCE', - multiple: true, - }), - status: Flags.string({ - description: 'Filters output to the specified status (success or failure).', - options: ['success', 'failure'], - env: 'SHOPIFY_FLAG_STATUS', - }), - } - - public async run(): Promise { - const {flags} = await this.parse(Logs) - - await checkFolderIsValidApp(flags.path) - - const appContextResult = await linkedAppContext({ - directory: flags.path, - clientId: flags['client-id'], - forceRelink: flags.reset, - userProvidedConfigName: flags.config, - }) - - const primaryStore = await storeContext({ - appContextResult, - storeFqdn: flags.store?.[0], - forceReselectStore: flags.reset, - }) - - const logOptions = { - ...appContextResult, - primaryStore, - storeFqdns: flags.store, - sources: flags.source, - status: flags.status, - format: (flags.json ? 'json' : 'text') as Format, - } + static flags = {...globalFlags, ...jsonFlag} - await logs(logOptions) - return {app: appContextResult.app} + async run(): Promise { + // Skip parsing so legacy flags and the removed sources subcommand receive the migration guidance. + throw new AbortError( + '`shopify app logs` and `shopify app logs sources` were removed in Shopify CLI 5.0. ' + + 'To see Function logs, run `shopify app dev`.', + ) } } diff --git a/packages/app/src/cli/index.ts b/packages/app/src/cli/index.ts index 38df624e2e4..049e3cb7177 100644 --- a/packages/app/src/cli/index.ts +++ b/packages/app/src/cli/index.ts @@ -13,7 +13,6 @@ 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 Sources from './commands/app/app-logs/sources.js' import EnvPull from './commands/app/env/pull.js' import EnvShow from './commands/app/env/show.js' import BulkExecute from './commands/app/bulk/execute.js' @@ -67,7 +66,6 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin 'app:security:record': SecurityRecord, 'app:security:review': SecurityReview, 'app:logs': Logs, - 'app:logs:sources': Sources, 'app:import:channel-config': ImportChannelConfig, 'app:import:custom-data-definitions': ImportCustomDataDefinitions, 'app:import:dashboard-extensions': ImportDashboardExtensions, diff --git a/packages/app/src/cli/services/app-logs/dev/write-app-logs.test.ts b/packages/app/src/cli/services/app-logs/dev/write-app-logs.test.ts index 229537a5f5c..7a6f5a8e2fd 100644 --- a/packages/app/src/cli/services/app-logs/dev/write-app-logs.test.ts +++ b/packages/app/src/cli/services/app-logs/dev/write-app-logs.test.ts @@ -1,5 +1,5 @@ import {writeAppLogsToFile} from './write-app-logs.js' -import {AppLogData, AppLogPayload, FunctionRunLog} from '../types.js' +import {AppLogData, FunctionRunLog} from '../types.js' import camelcaseKeys from '../camelcase-keys.js' import {joinPath} from '@shopify/cli-kit/node/path' import {readFile, inTemporaryDirectory} from '@shopify/cli-kit/node/fs' @@ -102,7 +102,7 @@ describe('writeAppLogsToFile', () => { }) }) -function expectedLogDataFromAppEvent(event: AppLogData, payload: AppLogPayload | any): string { +function expectedLogDataFromAppEvent(event: AppLogData, payload: unknown): string { const {cursor: _, ...eventWithoutCursor} = event const data: any = camelcaseKeys({ diff --git a/packages/app/src/cli/services/app-logs/logs-command/poll-app-logs.test.ts b/packages/app/src/cli/services/app-logs/logs-command/poll-app-logs.test.ts deleted file mode 100644 index 267dd59e01e..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/poll-app-logs.test.ts +++ /dev/null @@ -1,158 +0,0 @@ -import {pollAppLogs} from './poll-app-logs.js' -import {testDeveloperPlatformClient} from '../../../models/app/app.test-data.js' -import {describe, test, vi, expect} from 'vitest' - -vi.mock('@shopify/cli-kit/node/output') -vi.mock('@shopify/cli-kit/node/context/fqdn') -vi.mock('@shopify/cli-kit/node/http') -vi.mock('@shopify/cli-kit/node/ui') -vi.mock('../utils.js') - -const MOCKED_JWT_TOKEN = 'mockedJwtToken' -const MOCKED_CURSOR = 'mockedCursor' - -const RETURNED_CURSOR = '2024-05-23T19:17:02.321773Z' -const RESPONSE_DATA_SUCCESS = { - app_logs: [ - { - shop_id: 1, - api_client_id: 1830457, - payload: JSON.stringify({ - input: JSON.stringify({}), - input_bytes: 123, - output: JSON.stringify({}), - output_bytes: 182, - function_id: 'e57b4d31-2038-49ff-a0a1-1eea532414f7', - logs: '1\\n2\\n3\\n4\\n', - fuel_consumed: 512436, - }), - log_type: 'function_run', - cursor: RETURNED_CURSOR, - status: 'success', - source: 'my-function', - source_namespace: 'extensions', - log_timestamp: '2024-05-23T19:17:00.240053Z', - }, - ], - cursor: RETURNED_CURSOR, - status: 200, -} - -const EMPTY_FILTERS = {status: undefined, sources: undefined} - -// Custom mock response with .json method to mock response from poll -const createMockResponse = (data: any, status = 200, statusText = 'OK') => { - if (status !== 200 || data.errors) { - return { - errors: data.errors ?? [`Error with status ${status}`], - status, - } - } - - return { - app_logs: data.app_logs ?? [], - cursor: data.cursor, - status, - } -} - -describe('pollProcess', () => { - test('successful poll', async () => { - // Given - const mockedDeveloperPlatformClient = testDeveloperPlatformClient({ - appLogs: vi.fn().mockResolvedValueOnce(createMockResponse(RESPONSE_DATA_SUCCESS)), - }) - - // When - const result = await pollAppLogs({ - pollOptions: { - jwtToken: MOCKED_JWT_TOKEN, - cursor: MOCKED_CURSOR, - filters: EMPTY_FILTERS, - }, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: 'organizationId', - }) - - expect(result).toEqual({ - cursor: RETURNED_CURSOR, - appLogs: RESPONSE_DATA_SUCCESS.app_logs, - }) - }) - - test('successful poll with filters', async () => { - // Given - const mockedDeveloperPlatformClient = testDeveloperPlatformClient({ - appLogs: vi.fn().mockResolvedValueOnce(createMockResponse(RESPONSE_DATA_SUCCESS)), - }) - - // When - const result = await pollAppLogs({ - pollOptions: { - jwtToken: MOCKED_JWT_TOKEN, - cursor: MOCKED_CURSOR, - filters: {status: 'failure', sources: ['extensions.my-function', 'extensions.my-other-function']}, - }, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: 'organizationId', - }) - - expect(result).toEqual({ - cursor: RETURNED_CURSOR, - appLogs: [], - }) - }) - - test.each([ - [401, 'Unauthorized'], - [429, 'Rate limit'], - [500, 'Server Eror'], - ])('returns errors when response is %s', async (status, statusText) => { - // Given - const mockedDeveloperPlatformClient = testDeveloperPlatformClient({ - appLogs: vi.fn().mockResolvedValueOnce(createMockResponse({errors: [statusText]}, status)), - }) - - // When - const result = await pollAppLogs({ - pollOptions: { - jwtToken: MOCKED_JWT_TOKEN, - cursor: MOCKED_CURSOR, - filters: EMPTY_FILTERS, - }, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: 'organizationId', - }) - - // Then - expect(result).toEqual({ - errors: [{status, message: statusText}], - }) - }) - - test('polling with other error status', async () => { - // Given - const status = 422 - const statusText = 'Unprocessable' - const responseData = { - errors: [statusText], - } - - const mockedDeveloperPlatformClient = testDeveloperPlatformClient({ - appLogs: vi.fn().mockResolvedValueOnce(createMockResponse(responseData, status, statusText)), - }) - - // When/Then - await expect(() => - pollAppLogs({ - pollOptions: { - jwtToken: MOCKED_JWT_TOKEN, - cursor: MOCKED_CURSOR, - filters: EMPTY_FILTERS, - }, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: 'organizationId', - }), - ).rejects.toThrowError() - }) -}) diff --git a/packages/app/src/cli/services/app-logs/logs-command/poll-app-logs.ts b/packages/app/src/cli/services/app-logs/logs-command/poll-app-logs.ts deleted file mode 100644 index be3c1c2313c..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/poll-app-logs.ts +++ /dev/null @@ -1,51 +0,0 @@ -import {PollOptions, AppLogData, PollResponse, PollFilters} from '../types.js' -import {AppLogsError, AppLogsSuccess, DeveloperPlatformClient} from '../../../utilities/developer-platform-client.js' -import {AbortError} from '@shopify/cli-kit/node/error' - -interface PollAppLogsOptions { - pollOptions: PollOptions - developerPlatformClient: DeveloperPlatformClient - organizationId: string -} - -export const pollAppLogs = async ({ - pollOptions: {jwtToken, cursor, filters}, - developerPlatformClient, - organizationId, -}: PollAppLogsOptions): Promise => { - const response = await developerPlatformClient.appLogs({jwtToken, cursor}, organizationId) - const {errors, status} = response as AppLogsError - - if (status !== 200) { - if (status === 401 || status === 429 || status >= 500) { - return { - errors: errors.map((error) => ({status, message: error})), - } - } else { - throw new AbortError(`${errors.join(', ')} while fetching app logs`) - } - } - - const {cursor: responseCursor, app_logs: appLogs} = response as AppLogsSuccess - const filteredLogs = filterLogs(appLogs, filters) - - return { - cursor: responseCursor, - appLogs: filteredLogs, - } -} - -function filterLogs(appLogs: AppLogData[], filters: PollFilters) { - let filterLogs: AppLogData[] = appLogs - - if (filters.status !== undefined || filters.sources !== undefined) { - filterLogs = filterLogs.filter((log) => { - const statusMatch = filters.status === undefined ? true : log.status === filters.status - const sourceMatch = - filters.sources === undefined ? true : filters.sources.includes(`${log.source_namespace}.${log.source}`) - return statusMatch && sourceMatch - }) - } - - return filterLogs -} diff --git a/packages/app/src/cli/services/app-logs/logs-command/render-json-logs.test.ts b/packages/app/src/cli/services/app-logs/logs-command/render-json-logs.test.ts deleted file mode 100644 index 42359b91ec2..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/render-json-logs.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import {renderJsonLogs} from './render-json-logs.js' -import {pollAppLogs} from './poll-app-logs.js' -import {handleFetchAppLogsError, MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES} from '../utils.js' -import {testDeveloperPlatformClient} from '../../../models/app/app.test-data.js' -import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' -import {describe, expect, vi, test, beforeEach, afterEach} from 'vitest' -import {formatLocalDate} from '@shopify/cli-kit/common/string' - -vi.mock('./poll-app-logs') -vi.mock('../utils', async (importOriginal) => { - const mod = await importOriginal() - return { - ...mod, - fetchAppLogs: vi.fn(), - handleFetchAppLogsError: vi.fn(), - } -}) -vi.mock('@shopify/cli-kit/node/output') - -describe('renderJsonLogs', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.clearAllTimers() - }) - - test('should handle success response correctly', async () => { - const utcTime = '2024-09-14T05:00:00.000Z' - const localTime = formatLocalDate(utcTime) - - const mockSuccessResponse = { - cursor: 'next-cursor', - appLogs: [ - {shop_id: '1', payload: JSON.stringify({message: 'Log 1'}), log_timestamp: utcTime}, - {shop_id: '1', payload: JSON.stringify({message: 'Log 2'}), log_timestamp: utcTime}, - ], - } - const pollAppLogsMock = vi.fn().mockResolvedValue(mockSuccessResponse) - vi.mocked(pollAppLogs).mockImplementation(pollAppLogsMock) - - const storeNameById = new Map() - storeNameById.set('1', 'storeName') - await renderJsonLogs({ - pollOptions: {cursor: 'cursor', filters: {status: undefined, sources: undefined}, jwtToken: 'jwtToken'}, - options: { - variables: {shopIds: [1], apiKey: 'key'}, - developerPlatformClient: testDeveloperPlatformClient(), - }, - storeNameById, - organizationId: 'organizationId', - }) - - expect(outputResult).toHaveBeenNthCalledWith( - 1, - JSON.stringify({ - shopId: '1', - payload: {message: 'Log 1'}, - logTimestamp: utcTime, - localTime, - storeName: 'storeName', - }), - ) - expect(outputResult).toHaveBeenNthCalledWith( - 2, - JSON.stringify({ - shopId: '1', - payload: {message: 'Log 2'}, - logTimestamp: utcTime, - localTime, - storeName: 'storeName', - }), - ) - expect(pollAppLogs).toHaveBeenCalled() - expect(vi.getTimerCount()).toEqual(1) - }) - - test('should ignore logs with unknown store id', async () => { - const utcTime = '2024-09-14T05:00:00.000Z' - - const mockSuccessResponse = { - cursor: 'next-cursor', - appLogs: [{shop_id: '80809', payload: JSON.stringify({message: 'Log 2'}), log_timestamp: utcTime}], - } - const pollAppLogsMock = vi.fn().mockResolvedValue(mockSuccessResponse) - vi.mocked(pollAppLogs).mockImplementation(pollAppLogsMock) - - const storeNameById = new Map() - storeNameById.set('1', 'storeName') - await renderJsonLogs({ - pollOptions: {cursor: 'cursor', filters: {status: undefined, sources: undefined}, jwtToken: 'jwtToken'}, - options: { - variables: {shopIds: [1], apiKey: 'key'}, - developerPlatformClient: testDeveloperPlatformClient(), - }, - storeNameById, - organizationId: 'organizationId', - }) - - expect(outputResult).not.toHaveBeenCalled() - }) - - test('should retry at throttle interval when handleFetchAppLogsError returns null token', async () => { - const timeoutSpy = vi.spyOn(global, 'setTimeout') - const mockErrorResponse = { - errors: [{status: 401, message: 'Unauthorized'}], - } - const pollAppLogsMock = vi.fn().mockResolvedValue(mockErrorResponse) - vi.mocked(pollAppLogs).mockImplementation(pollAppLogsMock) - const throttleRetryInterval = 60000 - const handleFetchAppLogsErrorMock = vi.fn(() => { - return Promise.resolve({ - nextJwtToken: null, - retryIntervalMs: throttleRetryInterval, - resubscribeResult: 'not_attempted' as const, - }) - }) - vi.mocked(handleFetchAppLogsError).mockImplementation(handleFetchAppLogsErrorMock) - - const storeNameById = new Map() - storeNameById.set('1', 'storeName') - await renderJsonLogs({ - pollOptions: {cursor: 'cursor', filters: {status: undefined, sources: undefined}, jwtToken: 'jwtToken'}, - options: { - variables: {shopIds: [], apiKey: ''}, - developerPlatformClient: testDeveloperPlatformClient(), - }, - storeNameById, - organizationId: 'organizationId', - }) - - expect(handleFetchAppLogsError).toHaveBeenCalled() - expect(pollAppLogs).toHaveBeenCalled() - expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), throttleRetryInterval) - expect(vi.getTimerCount()).toEqual(1) - }) - - test('should stop polling after MAX consecutive resubscribe failures', async () => { - const mockErrorResponse = { - errors: [{status: 401, message: 'Unauthorized'}], - } - const pollAppLogsMock = vi.fn().mockResolvedValue(mockErrorResponse) - vi.mocked(pollAppLogs).mockImplementation(pollAppLogsMock) - const handleFetchAppLogsErrorMock = vi.fn(() => { - return Promise.resolve({nextJwtToken: null, retryIntervalMs: 60000, resubscribeResult: 'failed' as const}) - }) - vi.mocked(handleFetchAppLogsError).mockImplementation(handleFetchAppLogsErrorMock) - - const storeNameById = new Map() - storeNameById.set('1', 'storeName') - await renderJsonLogs({ - pollOptions: {cursor: 'cursor', filters: {status: undefined, sources: undefined}, jwtToken: 'jwtToken'}, - options: { - variables: {shopIds: [], apiKey: ''}, - developerPlatformClient: testDeveloperPlatformClient(), - }, - storeNameById, - organizationId: 'organizationId', - consecutiveResubscribeFailures: MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES - 1, - }) - - expect(handleFetchAppLogsError).toHaveBeenCalled() - expect(outputInfo).toHaveBeenCalledWith( - JSON.stringify({message: 'App log streaming session has expired. Please restart your dev session.'}), - ) - expect(vi.getTimerCount()).toEqual(0) - }) - - test('should handle error response and retry as expected', async () => { - const mockErrorResponse = { - errors: [{status: 500, message: 'Server Error'}], - } - const pollAppLogsMock = vi.fn().mockResolvedValue(mockErrorResponse) - vi.mocked(pollAppLogs).mockImplementation(pollAppLogsMock) - const mockRetryInterval = 1000 - const handleFetchAppLogsErrorMock = vi.fn((input) => { - input.onUnknownError(mockRetryInterval) - return new Promise<{ - retryIntervalMs: number - nextJwtToken: string | null - resubscribeResult: 'succeeded' | 'failed' | 'not_attempted' - }>((resolve, _reject) => { - resolve({nextJwtToken: 'new-jwt-token', retryIntervalMs: mockRetryInterval, resubscribeResult: 'not_attempted'}) - }) - }) - vi.mocked(handleFetchAppLogsError).mockImplementation(handleFetchAppLogsErrorMock) - - const storeNameById = new Map() - storeNameById.set('1', 'storeName') - await renderJsonLogs({ - pollOptions: {cursor: 'cursor', filters: {status: undefined, sources: undefined}, jwtToken: 'jwtToken'}, - options: { - variables: {shopIds: [], apiKey: ''}, - developerPlatformClient: testDeveloperPlatformClient(), - }, - storeNameById, - organizationId: 'organizationId', - }) - - expect(outputInfo).toHaveBeenCalledWith( - JSON.stringify({message: 'Error while polling app logs.', retry_in_ms: mockRetryInterval}), - ) - expect(pollAppLogs).toHaveBeenCalled() - expect(vi.getTimerCount()).toEqual(1) - }) -}) diff --git a/packages/app/src/cli/services/app-logs/logs-command/render-json-logs.ts b/packages/app/src/cli/services/app-logs/logs-command/render-json-logs.ts deleted file mode 100644 index 3d6adccff6e..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/render-json-logs.ts +++ /dev/null @@ -1,100 +0,0 @@ -import {pollAppLogs} from './poll-app-logs.js' -import {PollOptions, SubscribeOptions, ErrorResponse, SuccessResponse} from '../types.js' -import { - POLLING_INTERVAL_MS, - MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES, - handleFetchAppLogsError, - subscribeToAppLogs, - toFormattedAppLogJson, - parseAppLogPayload, -} from '../utils.js' -import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' - -export async function renderJsonLogs({ - pollOptions, - options: {variables, developerPlatformClient}, - storeNameById, - organizationId, - consecutiveResubscribeFailures = 0, -}: { - pollOptions: PollOptions - options: SubscribeOptions - storeNameById: Map - organizationId: string - consecutiveResubscribeFailures?: number -}): Promise { - const response = await pollAppLogs({pollOptions, developerPlatformClient, organizationId}) - let retryIntervalMs = POLLING_INTERVAL_MS - let nextJwtToken = pollOptions.jwtToken - let nextConsecutiveResubscribeFailures = consecutiveResubscribeFailures - - const errorResponse = response as ErrorResponse - - if (errorResponse.errors) { - const result = await handleFetchAppLogsError({ - response: errorResponse, - onThrottle: (retryIntervalMs) => { - outputInfo(JSON.stringify({message: 'Request throttled while polling app logs.', retry_in_ms: retryIntervalMs})) - }, - onUnknownError: (retryIntervalMs) => { - outputInfo(JSON.stringify({message: 'Error while polling app logs.', retry_in_ms: retryIntervalMs})) - }, - onResubscribe: () => { - return subscribeToAppLogs(developerPlatformClient, variables, organizationId) - }, - }) - - if (result.resubscribeResult === 'failed') { - nextConsecutiveResubscribeFailures += 1 - if (nextConsecutiveResubscribeFailures >= MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES) { - outputInfo(JSON.stringify({message: 'App log streaming session has expired. Please restart your dev session.'})) - return - } - } else if (result.resubscribeResult === 'succeeded') { - nextConsecutiveResubscribeFailures = 0 - } - - if (result.nextJwtToken) { - nextJwtToken = result.nextJwtToken - } - retryIntervalMs = result.retryIntervalMs - } else { - nextConsecutiveResubscribeFailures = 0 - } - - const {cursor: nextCursor, appLogs} = response as SuccessResponse - - if (appLogs) { - appLogs.forEach((log) => { - const storeName = storeNameById.get(log.shop_id?.toString()) - if (storeName === undefined) { - return - } - - outputResult( - toFormattedAppLogJson({ - appLog: log, - appLogPayload: parseAppLogPayload(log.payload, log.log_type), - storeName, - prettyPrint: false, - }), - ) - }) - } - - setTimeout(() => { - renderJsonLogs({ - options: {variables, developerPlatformClient}, - pollOptions: { - jwtToken: nextJwtToken || pollOptions.jwtToken, - cursor: nextCursor ?? pollOptions.cursor, - filters: pollOptions.filters, - }, - storeNameById, - organizationId, - consecutiveResubscribeFailures: nextConsecutiveResubscribeFailures, - }).catch((error) => { - throw error - }) - }, retryIntervalMs) -} diff --git a/packages/app/src/cli/services/app-logs/logs-command/ui.tsx b/packages/app/src/cli/services/app-logs/logs-command/ui.tsx deleted file mode 100644 index 2a543a72fb9..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/ui.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import {Logs} from './ui/components/Logs.js' -import {PollOptions, SubscribeOptions} from '../types.js' -import {subscribeToAppLogs} from '../utils.js' -import React from 'react' -import {render} from '@shopify/cli-kit/node/ui' - -export async function renderLogs({ - pollOptions, - options: {variables, developerPlatformClient}, - storeNameById, - organizationId, -}: { - pollOptions: PollOptions - options: SubscribeOptions - storeNameById: Map - organizationId: string -}) { - const resubscribeCallback = async () => { - return subscribeToAppLogs(developerPlatformClient, variables, organizationId) - } - - return render( - , - ) -} diff --git a/packages/app/src/cli/services/app-logs/logs-command/ui/components/Logs.test.tsx b/packages/app/src/cli/services/app-logs/logs-command/ui/components/Logs.test.tsx deleted file mode 100644 index ea7795a9f1b..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/ui/components/Logs.test.tsx +++ /dev/null @@ -1,544 +0,0 @@ -import {Logs} from './Logs.js' -import {usePollAppLogs} from './hooks/usePollAppLogs.js' -import { - AppLogPayload, - AppLogPrefix, - BackgroundExecutionReason, - FunctionRunLog, - NetworkAccessRequestExecutedLog, - NetworkAccessRequestExecutionInBackgroundLog, - NetworkAccessResponseFromCacheLog, -} from '../../../types.js' -import {testDeveloperPlatformClient} from '../../../../../models/app/app.test-data.js' -import {describe, test, vi, expect} from 'vitest' -import {render} from '@shopify/cli-kit/node/testing/ui' -import React from 'react' -import {unstyled} from '@shopify/cli-kit/node/output' - -vi.mock('./hooks/usePollAppLogs.js') - -const MOCKED_JWT_TOKEN = 'mockedJwtToken' -const MOCKED_CURSOR = 'mockedCursor' -const FUNCTION_ID = 'e57b4d31-2038-49ff-a0a1-1eea532414f7' -const FUEL_CONSUMED = 512436 -const TIME = '2024-06-18 16:02:04.868' - -const STATUS = 'success' -const SOURCE = 'my-function' -const LOGS = 'test logs' -const OUTPUT = {test: 'output'} -const INPUT = {test: 'input'} -const INPUT_BYTES = 10 -const OUTPUT_BYTES = 10 - -const MOCKED_ORGANIZATION_ID = '123' -const MOCKED_APP_ID = '456' - -const NETWORK_ACCESS_HTTP_REQUEST = { - url: 'https://api.example.com/hello', - method: 'GET', - headers: {}, - body: null, - policy: { - read_timeout_ms: 500, - }, -} -const NETWORK_ACCESS_HTTP_RESPONSE = { - status: 200, - body: 'Success', - headers: { - header1: 'value1', - }, -} - -const USE_POLL_APP_LOGS_ERRORS_RETURN_VALUE = { - errors: ['Test Error'], - appLogOutputs: [], -} - -const STORE_NAME_BY_ID = new Map() -STORE_NAME_BY_ID.set('1', 'my-store') - -const EMPTY_FILTERS = {status: undefined, sources: undefined} - -const appLogFunctionRunOutputs = ({ - prefix = {}, - appLog = {}, -}: { - prefix?: Partial - appLog?: Partial -} = {}): {appLog: FunctionRunLog; prefix: AppLogPrefix} => { - const defaultPrefix = { - functionId: FUNCTION_ID, - logTimestamp: TIME, - description: `export "run" executed in 0.5124M instructions`, - storeName: 'my-store', - status: STATUS === 'success' ? 'Success' : 'Failure', - source: SOURCE, - } - - const defaultAppLog = { - export: 'run', - input: INPUT, - inputBytes: INPUT_BYTES, - output: OUTPUT, - outputBytes: OUTPUT_BYTES, - logs: LOGS, - functionId: FUNCTION_ID, - fuelConsumed: FUEL_CONSUMED, - target: 'test.run', - errorMessage: 'errorMessage', - errorType: 'errorType', - inputQueryVariablesMetafieldValue: '{"key":"value"}', - inputQueryVariablesMetafieldNamespace: 'inputQueryVariablesMetafieldNamespace', - inputQueryVariablesMetafieldKey: 'inputQueryVariablesMetafieldKey', - } - - const resultPrefix = {...defaultPrefix, ...prefix} - const resultAppLog = {...defaultAppLog, ...appLog} - - return { - appLog: new FunctionRunLog(resultAppLog), - prefix: resultPrefix, - } -} - -describe('Logs', () => { - describe('App Logs', () => { - test('renders FunctionRunLog correctly with metafield data', async () => { - const appLogOutput = appLogFunctionRunOutputs() - - const mockedUsePollAppLogs = vi.fn().mockReturnValue({appLogOutputs: [appLogOutput], errors: []}) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const renderInstance = render( - , - ) - - const lastFrame = renderInstance.lastFrame() - - expect(unstyled(lastFrame!)).toMatchInlineSnapshot(` - "2024-06-18 16:02:04.868 my-store my-function Success export "run" executed in 0.5124M instructions - test logs - - Input Query Variables: - - Namespace: inputQueryVariablesMetafieldNamespace - Key: inputQueryVariablesMetafieldKey - - { - "key": "value" - } - - Input (10 bytes): - - { - "test": "input" - } - - Output (10 bytes): - - { - "test": "output" - }" - `) - - renderInstance.unmount() - }) - - test('renders FunctionRunLog correctly with nil metafield value', async () => { - const appLogOutput = appLogFunctionRunOutputs({ - appLog: { - inputQueryVariablesMetafieldValue: null, - }, - }) - const mockedUsePollAppLogs = vi.fn().mockReturnValue({appLogOutputs: [appLogOutput], errors: []}) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const renderInstance = render( - , - ) - - const lastFrame = renderInstance.lastFrame() - - expect(unstyled(lastFrame!)).toMatchInlineSnapshot(` - "2024-06-18 16:02:04.868 my-store my-function Success export "run" executed in 0.5124M instructions - test logs - - Input Query Variables: - - Namespace: inputQueryVariablesMetafieldNamespace - Key: inputQueryVariablesMetafieldKey - - Metafield is not set - - Input (10 bytes): - - { - "test": "input" - } - - Output (10 bytes): - - { - "test": "output" - }" - `) - - renderInstance.unmount() - }) - - test('redners FunctionRunLog without iqv when key and namespace are null', async () => { - const appLogOutput = appLogFunctionRunOutputs({ - appLog: { - inputQueryVariablesMetafieldValue: null, - inputQueryVariablesMetafieldNamespace: null, - inputQueryVariablesMetafieldKey: null, - }, - }) - - const mockedUsePollAppLogs = vi.fn().mockReturnValue({appLogOutputs: [appLogOutput], errors: []}) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const renderInstance = render( - , - ) - - const lastFrame = renderInstance.lastFrame() - - expect(unstyled(lastFrame!)).toMatchInlineSnapshot(` - "2024-06-18 16:02:04.868 my-store my-function Success export "run" executed in 0.5124M instructions - test logs - - Input (10 bytes): - - { - "test": "input" - } - - Output (10 bytes): - - { - "test": "output" - }" - `) - - renderInstance.unmount() - }) - }) - - describe('Network Access', () => { - test('renders NetworkAccessResponseFromCacheLog correctly', async () => { - const webhookLogOutput = { - appLog: new NetworkAccessResponseFromCacheLog({ - cacheEntryEpochMs: 1683904621000, - cacheTtlMs: 300000, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - httpResponse: NETWORK_ACCESS_HTTP_RESPONSE, - }), - prefix: { - functionId: FUNCTION_ID, - logTimestamp: TIME, - description: 'network access response from cache', - storeName: 'my-store', - status: 'Success', - source: SOURCE, - }, - } - - const mockedUsePollAppLogs = vi.fn().mockReturnValue({appLogOutputs: [webhookLogOutput], errors: []}) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const renderInstance = render( - , - ) - - const lastFrame = renderInstance.lastFrame() - - expect(unstyled(lastFrame!)).toMatchInlineSnapshot(` - "2024-06-18 16:02:04.868 my-store my-function Success network access response from cache - Cache write time: 2023-05-12T15:17:01.000Z - Cache TTL: 300 s - HTTP request: - { - "url": "https://api.example.com/hello", - "method": "GET", - "headers": {}, - "body": null, - "policy": { - "read_timeout_ms": 500 - } - } - HTTP response: - { - "status": 200, - "body": "Success", - "headers": { - "header1": "value1" - } - }" - `) - - renderInstance.unmount() - }) - - test('renders NetworkAccessRequestExecutedLog correctly with success', async () => { - const webhookLogOutput = { - appLog: new NetworkAccessRequestExecutedLog({ - attempt: 1, - connectTimeMs: 40, - writeReadTimeMs: 40, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - httpResponse: NETWORK_ACCESS_HTTP_RESPONSE, - error: null, - }), - prefix: { - functionId: FUNCTION_ID, - logTimestamp: TIME, - description: 'network access request executed in 80 ms', - status: 'Success', - storeName: 'my-store', - source: SOURCE, - }, - } - - const mockedUsePollAppLogs = vi.fn().mockReturnValue({appLogOutputs: [webhookLogOutput], errors: []}) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const renderInstance = render( - , - ) - - const lastFrame = renderInstance.lastFrame() - - expect(unstyled(lastFrame!)).toMatchInlineSnapshot(` - "2024-06-18 16:02:04.868 my-store my-function Success network access request executed in 80 ms - Attempt: 1 - Connect time: 40 ms - Write read time: 40 ms - HTTP request: - { - "url": "https://api.example.com/hello", - "method": "GET", - "headers": {}, - "body": null, - "policy": { - "read_timeout_ms": 500 - } - } - HTTP response: - { - "status": 200, - "body": "Success", - "headers": { - "header1": "value1" - } - }" - `) - - renderInstance.unmount() - }) - - test('renders NetworkAccessRequestExecutedLog correctly with failure', async () => { - const webhookLogOutput = { - appLog: new NetworkAccessRequestExecutedLog({ - attempt: 1, - connectTimeMs: null, - writeReadTimeMs: null, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - httpResponse: null, - error: 'Timeout Error', - }), - prefix: { - functionId: FUNCTION_ID, - logTimestamp: TIME, - description: 'network access request executed', - storeName: 'my-store', - status: 'Failure', - source: SOURCE, - }, - } - - const mockedUsePollAppLogs = vi.fn().mockReturnValue({appLogOutputs: [webhookLogOutput], errors: []}) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const renderInstance = render( - , - ) - - const lastFrame = renderInstance.lastFrame() - - expect(unstyled(lastFrame!)).toMatchInlineSnapshot(` - "2024-06-18 16:02:04.868 my-store my-function Failure network access request executed - Attempt: 1 - HTTP request: - { - "url": "https://api.example.com/hello", - "method": "GET", - "headers": {}, - "body": null, - "policy": { - "read_timeout_ms": 500 - } - } - Error: Timeout Error" - `) - - renderInstance.unmount() - }) - - test('renders NetworkAccessRequestExecutionInBackgroundLog correctly with NoCachedResponse', async () => { - const webhookLogOutput = { - appLog: new NetworkAccessRequestExecutionInBackgroundLog({ - reason: BackgroundExecutionReason.NoCachedResponse, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - }), - prefix: { - functionId: FUNCTION_ID, - logTimestamp: TIME, - description: 'network access request executing in background', - storeName: 'my-store', - status: 'Success', - source: SOURCE, - }, - } - - const mockedUsePollAppLogs = vi.fn().mockReturnValue({appLogOutputs: [webhookLogOutput], errors: []}) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const renderInstance = render( - , - ) - - const lastFrame = renderInstance.lastFrame() - - expect(unstyled(lastFrame!)).toMatchInlineSnapshot(` - "2024-06-18 16:02:04.868 my-store my-function Success network access request executing in background - Reason: No cached response available - HTTP request: - { - "url": "https://api.example.com/hello", - "method": "GET", - "headers": {}, - "body": null, - "policy": { - "read_timeout_ms": 500 - } - }" - `) - - renderInstance.unmount() - }) - - test('renders NetworkAccessRequestExecutionInBackgroundLog correctly with CacheAboutToExpire', async () => { - const webhookLogOutput = { - appLog: new NetworkAccessRequestExecutionInBackgroundLog({ - reason: BackgroundExecutionReason.CacheAboutToExpire, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - }), - prefix: { - functionId: FUNCTION_ID, - logTimestamp: TIME, - description: 'network access request executing in background', - storeName: 'my-store', - status: 'Success', - source: SOURCE, - }, - } - - const mockedUsePollAppLogs = vi.fn().mockReturnValue({appLogOutputs: [webhookLogOutput], errors: []}) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const renderInstance = render( - , - ) - - const lastFrame = renderInstance.lastFrame() - - expect(unstyled(lastFrame!)).toMatchInlineSnapshot(` - "2024-06-18 16:02:04.868 my-store my-function Success network access request executing in background - Reason: Cache is about to expire - HTTP request: - { - "url": "https://api.example.com/hello", - "method": "GET", - "headers": {}, - "body": null, - "policy": { - "read_timeout_ms": 500 - } - }" - `) - - renderInstance.unmount() - }) - }) - - test('handles errors', async () => { - const mockedUsePollAppLogs = vi.fn().mockReturnValue(USE_POLL_APP_LOGS_ERRORS_RETURN_VALUE) - vi.mocked(usePollAppLogs).mockImplementation(mockedUsePollAppLogs) - - const mockedResubscribeCallback = vi.fn().mockResolvedValueOnce(MOCKED_JWT_TOKEN) - - const renderInstance = render( - , - ) - - expect(unstyled(renderInstance.lastFrame()!)).toMatchInlineSnapshot(`"Test Error"`) - renderInstance.unmount() - }) -}) diff --git a/packages/app/src/cli/services/app-logs/logs-command/ui/components/Logs.tsx b/packages/app/src/cli/services/app-logs/logs-command/ui/components/Logs.tsx deleted file mode 100644 index 3fbcbd037d9..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/ui/components/Logs.tsx +++ /dev/null @@ -1,175 +0,0 @@ -import {usePollAppLogs} from './hooks/usePollAppLogs.js' -import { - PollOptions, - FunctionRunLog, - AppLogPrefix, - AppLogPayload, - NetworkAccessResponseFromCacheLog, - NetworkAccessRequestExecutionInBackgroundLog, - BackgroundExecutionReason, - NetworkAccessRequestExecutedLog, -} from '../../../types.js' -import {prettyPrintJsonIfPossible} from '../../../utils.js' -import {DeveloperPlatformClient} from '../../../../../utilities/developer-platform-client.js' - -import React, {FunctionComponent} from 'react' - -import {Box, Text} from '@shopify/cli-kit/node/ink' - -interface LogsProps { - resubscribeCallback: () => Promise - pollOptions: PollOptions - storeNameById: Map - developerPlatformClient: DeveloperPlatformClient - organizationId: string -} - -const getBackgroundExecutionReasonMessage = (reason: BackgroundExecutionReason): string => { - switch (reason) { - case BackgroundExecutionReason.NoCachedResponse: - return 'No cached response available' - case BackgroundExecutionReason.CacheAboutToExpire: - return 'Cache is about to expire' - case BackgroundExecutionReason.Unknown: - return 'Unknown reason' - } -} - -const Logs: FunctionComponent = ({ - pollOptions: {jwtToken, filters}, - resubscribeCallback, - storeNameById, - developerPlatformClient, - organizationId, -}) => { - const {appLogOutputs, errors} = usePollAppLogs({ - filters, - initialJwt: jwtToken, - resubscribeCallback, - storeNameById, - developerPlatformClient, - organizationId, - }) - - return ( - <> - {appLogOutputs.map( - ( - { - appLog, - prefix, - }: { - appLog: AppLogPayload - prefix: AppLogPrefix - }, - index: number, - ) => ( - - {/* update: use invocationId after https://github.com/Shopify/shopify-functions/issues/235 */} - - - {prefix.logTimestamp} - {`${prefix.storeName.split('.')[0]}`} - {prefix.source} - {prefix.status} - {prefix.description} - - - - {appLog instanceof FunctionRunLog && ( - - {appLog.logs} - {appLog.inputQueryVariablesMetafieldKey && appLog.inputQueryVariablesMetafieldNamespace ? ( - - Input Query Variables: - - Namespace: - {appLog.inputQueryVariablesMetafieldNamespace} - - - Key: - {appLog.inputQueryVariablesMetafieldKey} - - - - {prettyPrintJsonIfPossible(appLog.inputQueryVariablesMetafieldValue) ?? ( - Metafield is not set - )} - - - - ) : null} - {appLog.input ? ( - - - Input ({appLog.inputBytes} bytes): - - - {prettyPrintJsonIfPossible(appLog.input)} - - - ) : null} - {appLog.output ? ( - - - Output ({appLog.outputBytes} bytes): - - - {prettyPrintJsonIfPossible(appLog.output)} - - - ) : null} - - )} - {appLog instanceof NetworkAccessResponseFromCacheLog && ( - - Cache write time: {new Date(appLog.cacheEntryEpochMs).toISOString()} - Cache TTL: {appLog.cacheTtlMs / 1000} s - HTTP request: - {prettyPrintJsonIfPossible(appLog.httpRequest)} - HTTP response: - {prettyPrintJsonIfPossible(appLog.httpResponse)} - - )} - {appLog instanceof NetworkAccessRequestExecutionInBackgroundLog && ( - - Reason: {getBackgroundExecutionReasonMessage(appLog.reason)} - HTTP request: - {prettyPrintJsonIfPossible(appLog.httpRequest)} - - )} - {appLog instanceof NetworkAccessRequestExecutedLog && ( - - Attempt: {appLog.attempt} - {appLog.connectTimeMs ? Connect time: {appLog.connectTimeMs} ms : null} - {appLog.writeReadTimeMs ? Write read time: {appLog.writeReadTimeMs} ms : null} - HTTP request: - {prettyPrintJsonIfPossible(appLog.httpRequest)} - {appLog.httpResponse ? ( - - HTTP response: - {prettyPrintJsonIfPossible(appLog.httpResponse)} - - ) : null} - {appLog.error ? Error: {appLog.error} : null} - - )} - - - ), - )} - - {errors.length > 0 && ( - - {errors.map((error, index) => ( - - {error} - - ))} - - )} - - ) -} - -export {Logs} diff --git a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx b/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx deleted file mode 100644 index 9d4964deaf8..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.test.tsx +++ /dev/null @@ -1,624 +0,0 @@ -import {usePollAppLogs} from './usePollAppLogs.js' -import {pollAppLogs} from '../../../poll-app-logs.js' -import { - LOG_TYPE_REQUEST_EXECUTION, - LOG_TYPE_REQUEST_EXECUTION_IN_BACKGROUND, - LOG_TYPE_RESPONSE_FROM_CACHE, - POLLING_ERROR_RETRY_INTERVAL_MS, - POLLING_INTERVAL_MS, - POLLING_THROTTLE_RETRY_INTERVAL_MS, - MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES, - parseFunctionRunPayload, -} from '../../../../utils.js' -import { - BackgroundExecutionReason, - NetworkAccessRequestExecutedLog, - NetworkAccessRequestExecutionInBackgroundLog, - NetworkAccessResponseFromCacheLog, -} from '../../../../types.js' -import {testDeveloperPlatformClient} from '../../../../../../models/app/app.test-data.js' -import {render} from '@shopify/cli-kit/node/testing/ui' -import {test, describe, vi, beforeEach, afterEach, expect} from 'vitest' -import React from 'react' - -vi.mock('../../../poll-app-logs.js') - -const MOCKED_JWT_TOKEN = 'mockedJwtToken' -const NEW_JWT_TOKEN = 'newJwt' -const RETURNED_CURSOR = '2024-05-23T19:17:02.321773Z' -const FUNCTION_ID = 'e57b4d31-2038-49ff-a0a1-1eea532414f7' -const FUEL_CONSUMED = 512436 -const TIME = '2024-06-18 16:02:04' - -const LOG_TYPE = 'function_run' -const STATUS = 'success' -const SOURCE = 'my-function' -const LOGS = 'test logs' -const OUTPUT = {test: 'output'} -const INPUT = {test: 'input'} -const INPUT_BYTES = 10 -const OUTPUT_BYTES = 10 -const TARGET = 'test.run' - -const NETWORK_ACCESS_HTTP_REQUEST = { - url: 'https://api.example.com/hello', - method: 'GET', - headers: {}, - body: null, - policy: { - read_timeout_ms: 500, - }, -} -const NETWORK_ACCESS_HTTP_RESPONSE = { - status: 200, - body: 'Success', - headers: { - header1: 'value1', - }, -} - -const NETWORK_ACCESS_REQUEST_EXECUTION_SUCCESS_PAYLOAD = { - attempt: 1, - connect_time_ms: 40, - write_read_time_ms: 40, - http_request: NETWORK_ACCESS_HTTP_REQUEST, - http_response: NETWORK_ACCESS_HTTP_RESPONSE, -} -const NETWORK_ACCESS_REQUEST_EXECUTION_FAILURE_PAYLOAD = { - attempt: 1, - http_request: NETWORK_ACCESS_HTTP_REQUEST, - error: 'Timeout Error', -} - -const NETWORK_ACCESS_RESPONSE_FROM_CACHE_PAYLOAD = { - cache_entry_epoch_ms: 1683904621000, - cache_ttl_ms: 300000, - http_request: NETWORK_ACCESS_HTTP_REQUEST, - http_response: NETWORK_ACCESS_HTTP_RESPONSE, -} - -const NETWORK_ACCESS_REQUEST_EXECUTION_IN_BACKGROUND_NO_CACHE_PAYLOAD = { - reason: 'no_cached_response', - http_request: NETWORK_ACCESS_HTTP_REQUEST, -} - -const NETWORK_ACCESS_REQUEST_EXECUTION_IN_BACKGROUND_CACHE_ABOUT_TO_EXPIRE_PAYLOAD = { - reason: 'cached_response_about_to_expire', - http_request: NETWORK_ACCESS_HTTP_REQUEST, -} - -const POLL_APP_LOGS_FOR_LOGS_RESPONSE = { - cursor: RETURNED_CURSOR, - appLogs: [ - { - shop_id: 1, - api_client_id: 1830457, - payload: JSON.stringify({ - export: 'run', - input: INPUT, - input_bytes: INPUT_BYTES, - output: OUTPUT, - output_bytes: OUTPUT_BYTES, - function_id: FUNCTION_ID, - logs: LOGS, - fuel_consumed: FUEL_CONSUMED, - target: TARGET, - }), - log_type: LOG_TYPE, - cursor: RETURNED_CURSOR, - status: STATUS, - source: SOURCE, - source_namespace: 'extensions', - log_timestamp: TIME, - }, - { - shop_id: 1, - api_client_id: 1830457, - payload: JSON.stringify(NETWORK_ACCESS_RESPONSE_FROM_CACHE_PAYLOAD), - log_type: LOG_TYPE_RESPONSE_FROM_CACHE, - cursor: RETURNED_CURSOR, - status: STATUS, - source: SOURCE, - source_namespace: 'extensions', - log_timestamp: TIME, - }, - { - shop_id: 1, - api_client_id: 1830457, - payload: JSON.stringify(NETWORK_ACCESS_REQUEST_EXECUTION_SUCCESS_PAYLOAD), - log_type: LOG_TYPE_REQUEST_EXECUTION, - cursor: RETURNED_CURSOR, - status: STATUS, - source: SOURCE, - source_namespace: 'extensions', - log_timestamp: TIME, - }, - { - shop_id: 1, - api_client_id: 1830457, - payload: JSON.stringify(NETWORK_ACCESS_REQUEST_EXECUTION_FAILURE_PAYLOAD), - log_type: LOG_TYPE_REQUEST_EXECUTION, - cursor: RETURNED_CURSOR, - status: 'failure', - source: SOURCE, - source_namespace: 'extensions', - log_timestamp: TIME, - }, - { - shop_id: 1, - api_client_id: 1830457, - payload: JSON.stringify(NETWORK_ACCESS_REQUEST_EXECUTION_IN_BACKGROUND_NO_CACHE_PAYLOAD), - log_type: LOG_TYPE_REQUEST_EXECUTION_IN_BACKGROUND, - cursor: RETURNED_CURSOR, - status: STATUS, - source: SOURCE, - source_namespace: 'extensions', - log_timestamp: TIME, - }, - { - shop_id: 1, - api_client_id: 1830457, - payload: JSON.stringify(NETWORK_ACCESS_REQUEST_EXECUTION_IN_BACKGROUND_CACHE_ABOUT_TO_EXPIRE_PAYLOAD), - log_type: LOG_TYPE_REQUEST_EXECUTION_IN_BACKGROUND, - cursor: RETURNED_CURSOR, - status: STATUS, - source: SOURCE, - source_namespace: 'extensions', - log_timestamp: TIME, - }, - ], -} - -const MOCKED_ORGANIZATION_ID = '123' -const MOCKED_APP_ID = '456' - -const POLL_APP_LOGS_FOR_LOGS_401_RESPONSE = { - errors: [{status: 401, message: 'Unauthorized'}], -} - -const POLL_APP_LOGS_FOR_LOGS_429_RESPONSE = { - errors: [{status: 429, message: 'Error Message'}], -} - -const POLL_APP_LOGS_FOR_LOGS_UNKNOWN_RESPONSE = { - errors: [{status: 422, message: 'Unprocessable'}], -} - -const STORE_NAME = 'Test Store' -const STORE_ID = '1' -const STORE_NAME_BY_ID = new Map([[STORE_ID, STORE_NAME]]) - -const EMPTY_FILTERS = {status: undefined, sources: undefined} - -describe('usePollAppLogs', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.clearAllTimers() - }) - - test('returns logs on successful poll', async () => { - const mockedPollAppLogs = vi.fn().mockResolvedValue(POLL_APP_LOGS_FOR_LOGS_RESPONSE) - vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs) - - const mockedDeveloperPlatformClient = testDeveloperPlatformClient() - - const resubscribeCallback = vi.fn().mockResolvedValue(NEW_JWT_TOKEN) - - const hook = renderHook(() => - usePollAppLogs({ - initialJwt: MOCKED_JWT_TOKEN, - filters: EMPTY_FILTERS, - resubscribeCallback, - storeNameById: STORE_NAME_BY_ID, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }), - ) - - // needed to await the render - await vi.advanceTimersByTimeAsync(0) - - // Wait for the async polling function to execute - await waitForMockCalls(mockedPollAppLogs, 1) - // Flush React 19 batched state updates so hook.lastResult reflects the new state - await vi.advanceTimersByTimeAsync(0) - - expect(mockedPollAppLogs).toHaveBeenCalledTimes(1) - - expect(hook.lastResult?.appLogOutputs).toHaveLength(6) - - expect(hook.lastResult?.appLogOutputs[0]!.appLog).toEqual( - parseFunctionRunPayload(POLL_APP_LOGS_FOR_LOGS_RESPONSE.appLogs[0]!.payload), - ) - expect(hook.lastResult?.appLogOutputs[0]!.prefix).toEqual({ - status: 'Success', - source: SOURCE, - description: `export "run" executed in ${(FUEL_CONSUMED / 1000000).toFixed(4)}M instructions`, - logTimestamp: TIME, - storeName: STORE_NAME, - }) - - expect(hook.lastResult?.appLogOutputs[1]!.appLog).toEqual( - new NetworkAccessResponseFromCacheLog({ - cacheEntryEpochMs: 1683904621000, - cacheTtlMs: 300000, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - httpResponse: NETWORK_ACCESS_HTTP_RESPONSE, - }), - ) - expect(hook.lastResult?.appLogOutputs[1]!.prefix).toEqual({ - status: 'Success', - source: SOURCE, - description: `network access response retrieved from cache`, - logTimestamp: TIME, - storeName: STORE_NAME, - }) - - expect(hook.lastResult?.appLogOutputs[2]!.appLog).toEqual( - new NetworkAccessRequestExecutedLog({ - attempt: 1, - connectTimeMs: 40, - writeReadTimeMs: 40, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - httpResponse: NETWORK_ACCESS_HTTP_RESPONSE, - error: null, - }), - ) - expect(hook.lastResult?.appLogOutputs[2]!.prefix).toEqual({ - status: 'Success', - source: SOURCE, - description: `network access request executed in 80 ms`, - logTimestamp: TIME, - storeName: STORE_NAME, - }) - - expect(hook.lastResult?.appLogOutputs[3]!.appLog).toEqual( - new NetworkAccessRequestExecutedLog({ - attempt: 1, - connectTimeMs: null, - writeReadTimeMs: null, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - httpResponse: null, - error: 'Timeout Error', - }), - ) - expect(hook.lastResult?.appLogOutputs[3]!.prefix).toEqual({ - status: 'Failure', - source: SOURCE, - description: `network access request executed`, - logTimestamp: TIME, - storeName: STORE_NAME, - }) - - expect(hook.lastResult?.appLogOutputs[4]!.appLog).toEqual( - new NetworkAccessRequestExecutionInBackgroundLog({ - reason: BackgroundExecutionReason.NoCachedResponse, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - }), - ) - expect(hook.lastResult?.appLogOutputs[4]!.prefix).toEqual({ - status: 'Success', - source: SOURCE, - description: `network access request executing in background`, - logTimestamp: TIME, - storeName: STORE_NAME, - }) - - expect(hook.lastResult?.appLogOutputs[5]!.appLog).toEqual( - new NetworkAccessRequestExecutionInBackgroundLog({ - reason: BackgroundExecutionReason.CacheAboutToExpire, - httpRequest: NETWORK_ACCESS_HTTP_REQUEST, - }), - ) - expect(hook.lastResult?.appLogOutputs[5]!.prefix).toEqual({ - status: 'Success', - source: SOURCE, - description: `network access request executing in background`, - logTimestamp: TIME, - storeName: STORE_NAME, - }) - - expect(vi.getTimerCount()).toEqual(1) - }) - - test('refreshes jwt after 401', async () => { - const mockedPollAppLogs = vi - .fn() - .mockResolvedValueOnce(POLL_APP_LOGS_FOR_LOGS_401_RESPONSE) - .mockResolvedValueOnce(POLL_APP_LOGS_FOR_LOGS_RESPONSE) - vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs) - - const resubscribeCallback = vi.fn().mockResolvedValue(NEW_JWT_TOKEN) - const mockedDeveloperPlatformClient = testDeveloperPlatformClient() - - renderHook(() => - usePollAppLogs({ - initialJwt: MOCKED_JWT_TOKEN, - filters: EMPTY_FILTERS, - resubscribeCallback, - storeNameById: STORE_NAME_BY_ID, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }), - ) - - // needed to await the render - await vi.advanceTimersByTimeAsync(0) - - // Wait for the async polling function to execute - await waitForMockCalls(mockedPollAppLogs, 1) - - // Initial invocation, 401 returned - expect(mockedPollAppLogs).toHaveBeenNthCalledWith(1, { - pollOptions: { - jwtToken: MOCKED_JWT_TOKEN, - cursor: '', - filters: EMPTY_FILTERS, - }, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }) - expect(resubscribeCallback).toHaveBeenCalledOnce() - - // Follow up invocation, which invokes resubscribeCallback - await vi.advanceTimersToNextTimerAsync() - - // Wait for the second async polling function call to execute - await waitForMockCalls(mockedPollAppLogs, 2) - - expect(mockedPollAppLogs).toHaveBeenNthCalledWith(2, { - pollOptions: {jwtToken: NEW_JWT_TOKEN, cursor: '', filters: EMPTY_FILTERS}, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }) - - expect(vi.getTimerCount()).toEqual(1) - }) - - test('retries after throttle interval on 429', async () => { - const mockedPollAppLogs = vi - .fn() - .mockResolvedValueOnce(POLL_APP_LOGS_FOR_LOGS_429_RESPONSE) - .mockResolvedValueOnce(POLL_APP_LOGS_FOR_LOGS_RESPONSE) - vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs) - - const mockedDeveloperPlatformClient = testDeveloperPlatformClient() - - const timeoutSpy = vi.spyOn(global, 'setTimeout') - - const resubscribeCallback = vi.fn().mockResolvedValue(NEW_JWT_TOKEN) - - const hook = renderHook(() => - usePollAppLogs({ - initialJwt: MOCKED_JWT_TOKEN, - filters: EMPTY_FILTERS, - resubscribeCallback, - storeNameById: STORE_NAME_BY_ID, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }), - ) - - // needed to await the render - await vi.advanceTimersByTimeAsync(0) - - // Wait for the async polling function to execute - await waitForMockCalls(mockedPollAppLogs, 1) - // Flush React 19 batched state updates so hook.lastResult reflects the new state - await vi.advanceTimersByTimeAsync(0) - - // Initial invocation, 429 returned - expect(mockedPollAppLogs).toHaveBeenCalledTimes(1) - - expect(hook.lastResult?.appLogOutputs).toHaveLength(0) - expect(hook.lastResult?.errors[0]).toEqual('Request throttled while polling app logs.') - expect(hook.lastResult?.errors[1]).toEqual('Retrying in 60s') - - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), POLLING_THROTTLE_RETRY_INTERVAL_MS) - - await vi.advanceTimersToNextTimerAsync() - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), POLLING_INTERVAL_MS) - - expect(vi.getTimerCount()).toEqual(1) - timeoutSpy.mockRestore() - }) - - test('retries after unknown error', async () => { - const mockedPollAppLogs = vi - .fn() - .mockResolvedValueOnce(POLL_APP_LOGS_FOR_LOGS_UNKNOWN_RESPONSE) - .mockResolvedValueOnce(POLL_APP_LOGS_FOR_LOGS_RESPONSE) - vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs) - - const mockedDeveloperPlatformClient = testDeveloperPlatformClient() - - const timeoutSpy = vi.spyOn(global, 'setTimeout') - - const resubscribeCallback = vi.fn().mockResolvedValue(NEW_JWT_TOKEN) - - const hook = renderHook(() => - usePollAppLogs({ - initialJwt: MOCKED_JWT_TOKEN, - filters: EMPTY_FILTERS, - resubscribeCallback, - storeNameById: STORE_NAME_BY_ID, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }), - ) - - // needed to await the render - await vi.advanceTimersByTimeAsync(0) - - // Wait for the async polling function to execute - await waitForMockCalls(mockedPollAppLogs, 1) - // Flush React 19 batched state updates so hook.lastResult reflects the new state - await vi.advanceTimersByTimeAsync(0) - - // Initial invocation, 422 returned - expect(mockedPollAppLogs).toHaveBeenCalledTimes(1) - - expect(hook.lastResult?.appLogOutputs).toHaveLength(0) - expect(hook.lastResult?.errors[0]).toEqual('Error while polling app logs') - expect(hook.lastResult?.errors[1]).toEqual('Retrying in 5s') - - expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), POLLING_ERROR_RETRY_INTERVAL_MS) - - await vi.advanceTimersToNextTimerAsync() - // Flush React 19 batched state updates - await vi.advanceTimersByTimeAsync(0) - expect(hook.lastResult?.appLogOutputs).toHaveLength(6) - expect(hook.lastResult?.errors).toHaveLength(0) - expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), POLLING_INTERVAL_MS) - - expect(vi.getTimerCount()).toEqual(1) - timeoutSpy.mockRestore() - }) - - test('clears error on success', async () => { - const mockedPollAppLogs = vi - .fn() - .mockResolvedValueOnce(POLL_APP_LOGS_FOR_LOGS_429_RESPONSE) - .mockResolvedValueOnce(POLL_APP_LOGS_FOR_LOGS_RESPONSE) - vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs) - - const mockedDeveloperPlatformClient = testDeveloperPlatformClient() - - const hook = renderHook(() => - usePollAppLogs({ - initialJwt: MOCKED_JWT_TOKEN, - filters: EMPTY_FILTERS, - resubscribeCallback: vi.fn().mockResolvedValue(MOCKED_JWT_TOKEN), - storeNameById: STORE_NAME_BY_ID, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }), - ) - - // initial poll with errors - await vi.advanceTimersByTimeAsync(0) - // Wait for the async polling function to execute - await waitForMockCalls(mockedPollAppLogs, 1) - // Flush React 19 batched state updates so hook.lastResult reflects the new state - await vi.advanceTimersByTimeAsync(0) - expect(hook.lastResult?.errors).toHaveLength(2) - - // second poll with no errors - await vi.advanceTimersToNextTimerAsync() - // Flush React 19 batched state updates - await vi.advanceTimersByTimeAsync(0) - expect(hook.lastResult?.errors).toHaveLength(0) - }) - - test('stops polling after MAX consecutive resubscribe failures', async () => { - const mockedPollAppLogs = vi.fn().mockResolvedValue(POLL_APP_LOGS_FOR_LOGS_401_RESPONSE) - vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs) - - const mockedDeveloperPlatformClient = testDeveloperPlatformClient() - const resubscribeCallback = vi.fn().mockRejectedValue(new Error('Session expired')) - - const hook = renderHook(() => - usePollAppLogs({ - initialJwt: MOCKED_JWT_TOKEN, - filters: EMPTY_FILTERS, - resubscribeCallback, - storeNameById: STORE_NAME_BY_ID, - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }), - ) - - // needed to await the render - await vi.advanceTimersByTimeAsync(0) - - // Wait for the first poll - await waitForMockCalls(mockedPollAppLogs, 1) - - // Advance through MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES - 1 more polls - for (let i = 1; i < MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES; i++) { - // eslint-disable-next-line no-await-in-loop - await vi.advanceTimersToNextTimerAsync() - // eslint-disable-next-line no-await-in-loop - await waitForMockCalls(mockedPollAppLogs, i + 1) - } - - // Flush React 19 batched state updates - await vi.advanceTimersByTimeAsync(0) - - expect(hook.lastResult?.errors).toEqual(['App log streaming session has expired. Please restart your dev session.']) - // Polling should have stopped - no more timers scheduled - expect(vi.getTimerCount()).toEqual(0) - }) - - test("ignores logs from stores that don't have a matching shop name", async () => { - const mockedPollAppLogs = vi.fn().mockResolvedValue(POLL_APP_LOGS_FOR_LOGS_RESPONSE) - vi.mocked(pollAppLogs).mockImplementation(mockedPollAppLogs) - - const mockedDeveloperPlatformClient = testDeveloperPlatformClient() - - const resubscribeCallback = vi.fn().mockResolvedValue(NEW_JWT_TOKEN) - - const hook = renderHook(() => - usePollAppLogs({ - initialJwt: MOCKED_JWT_TOKEN, - filters: EMPTY_FILTERS, - resubscribeCallback, - storeNameById: new Map(), - developerPlatformClient: mockedDeveloperPlatformClient, - organizationId: MOCKED_ORGANIZATION_ID, - }), - ) - - // needed to await the render - await vi.advanceTimersByTimeAsync(0) - - expect(hook.lastResult?.appLogOutputs).toHaveLength(0) - expect(mockedPollAppLogs).toHaveBeenCalledTimes(1) - }) -}) - -function renderHook(renderHookCallback: () => THookResult) { - const result: { - lastResult: THookResult | undefined - } = { - lastResult: undefined, - } - - const MockComponent = () => { - const hookResult = renderHookCallback() - result.lastResult = hookResult - - return null - } - - render() - - return result -} - -async function waitForMockCalls(mockFn: any, expectedCallCount: number, timeoutMs = 2000): Promise { - return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => - reject( - new Error( - `Mock timeout: expected ${expectedCallCount} calls, got ${mockFn.mock.calls.length} after ${timeoutMs}ms`, - ), - ), - timeoutMs, - ) - - const check = () => { - if (mockFn.mock.calls.length >= expectedCallCount) { - clearTimeout(timeout) - resolve() - } else { - setImmediate(check) - } - } - check() - }) -} diff --git a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.ts b/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.ts deleted file mode 100644 index d2c9016fad4..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/usePollAppLogs.ts +++ /dev/null @@ -1,196 +0,0 @@ -import {useSelfAdjustingInterval} from './useSelfAdjustingInterval.js' -import { - ONE_MILLION, - POLLING_INTERVAL_MS, - MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES, - parseFunctionRunPayload, - LOG_TYPE_FUNCTION_RUN, - LOG_TYPE_RESPONSE_FROM_CACHE, - parseNetworkAccessResponseFromCachePayload, - LOG_TYPE_REQUEST_EXECUTION_IN_BACKGROUND, - parseNetworkAccessRequestExecutionInBackgroundPayload, - LOG_TYPE_REQUEST_EXECUTION, - parseNetworkAccessRequestExecutedPayload, - handleFetchAppLogsError, -} from '../../../../utils.js' -import {DeveloperPlatformClient} from '../../../../../../utilities/developer-platform-client.js' -import {ErrorResponse, SuccessResponse, AppLogOutput, PollFilters, AppLogPayload} from '../../../../types.js' -import {pollAppLogs} from '../../../poll-app-logs.js' -import {useState, Dispatch, SetStateAction, useRef, useCallback} from 'react' -import {formatLocalDate} from '@shopify/cli-kit/common/string' - -interface UsePollAppLogsOptions { - initialJwt: string - filters: PollFilters - resubscribeCallback: () => Promise - storeNameById: Map - developerPlatformClient: DeveloperPlatformClient - organizationId: string -} - -async function performPoll({ - jwtToken, - cursor, - filters, - storeNameById, - organizationId, - setErrors, - setAppLogOutputs, - resubscribeCallback, - developerPlatformClient, -}: { - jwtToken: string - cursor?: string - filters: PollFilters - storeNameById: Map - organizationId: string - setErrors: Dispatch> - setAppLogOutputs: Dispatch> - resubscribeCallback: () => Promise - developerPlatformClient: DeveloperPlatformClient -}) { - let nextJwtToken = jwtToken - let retryIntervalMs = POLLING_INTERVAL_MS - let nextCursor = cursor - const response = await pollAppLogs({ - pollOptions: {jwtToken, cursor, filters}, - developerPlatformClient, - organizationId, - }) - - let resubscribeResult: 'succeeded' | 'failed' | 'not_attempted' = 'not_attempted' - const errorResponse = response as ErrorResponse - - if (errorResponse.errors) { - const result = await handleFetchAppLogsError({ - response: errorResponse, - onThrottle: (retryIntervalMs) => { - setErrors(['Request throttled while polling app logs.', `Retrying in ${retryIntervalMs / 1000}s`]) - }, - onUnknownError: (retryIntervalMs) => { - setErrors(['Error while polling app logs', `Retrying in ${retryIntervalMs / 1000}s`]) - }, - onResubscribe: () => { - return resubscribeCallback() - }, - }) - - resubscribeResult = result.resubscribeResult - - if (result.nextJwtToken) { - nextJwtToken = result.nextJwtToken - } - retryIntervalMs = result.retryIntervalMs - } else { - setErrors((errors) => (errors.length ? [] : errors)) - - const {appLogs} = response as SuccessResponse - nextCursor = (response as SuccessResponse).cursor - - if (appLogs) { - for (const log of appLogs) { - let appLog: AppLogPayload - let description - let executionTime - - const storeName = storeNameById.get(log.shop_id.toString()) - if (storeName === undefined) { - continue - } - - switch (log.log_type) { - case LOG_TYPE_FUNCTION_RUN: - appLog = parseFunctionRunPayload(log.payload) - description = `export "${appLog.export}" executed in ${(appLog.fuelConsumed / ONE_MILLION).toFixed( - 4, - )}M instructions` - break - case LOG_TYPE_RESPONSE_FROM_CACHE: - appLog = parseNetworkAccessResponseFromCachePayload(log.payload) - description = 'network access response retrieved from cache' - break - case LOG_TYPE_REQUEST_EXECUTION_IN_BACKGROUND: - appLog = parseNetworkAccessRequestExecutionInBackgroundPayload(log.payload) - description = 'network access request executing in background' - break - case LOG_TYPE_REQUEST_EXECUTION: - appLog = parseNetworkAccessRequestExecutedPayload(log.payload) - executionTime = - appLog.connectTimeMs && appLog.writeReadTimeMs ? appLog.connectTimeMs + appLog.writeReadTimeMs : null - description = `network access request executed${executionTime ? ` in ${executionTime} ms` : ''}` - break - default: - continue - } - - const prefix = { - status: log.status === 'success' ? 'Success' : 'Failure', - source: log.source, - storeName, - description, - logTimestamp: formatLocalDate(log.log_timestamp), - } - - if (appLog) { - setAppLogOutputs((prev) => [...prev, {appLog, prefix}]) - } - } - } - } - - return {nextJwtToken, retryIntervalMs, cursor: nextCursor ?? cursor, resubscribeResult} -} - -export function usePollAppLogs({ - initialJwt, - filters, - resubscribeCallback, - storeNameById, - developerPlatformClient, - organizationId, -}: UsePollAppLogsOptions) { - const [errors, setErrors] = useState([]) - const [appLogOutputs, setAppLogOutputs] = useState([]) - const nextJwtToken = useRef(initialJwt) - const retryIntervalMs = useRef(0) - const cursor = useRef('') - const consecutiveResubscribeFailures = useRef(0) - - const performPollCallback = useCallback(async () => { - const res = await performPoll({ - jwtToken: nextJwtToken.current, - cursor: cursor.current, - filters, - storeNameById, - setErrors, - setAppLogOutputs, - resubscribeCallback, - developerPlatformClient, - organizationId, - }) - - if (res.resubscribeResult === 'failed') { - consecutiveResubscribeFailures.current += 1 - if (consecutiveResubscribeFailures.current >= MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES) { - setErrors(['App log streaming session has expired. Please restart your dev session.']) - return {retryIntervalMs: 0} - } - } else if (res.resubscribeResult === 'succeeded') { - consecutiveResubscribeFailures.current = 0 - } - - // ESLint is concerned about these updates being atomic, but the approach to useSelfAdjustingInterval ensures that is the case. - // eslint-disable-next-line require-atomic-updates - nextJwtToken.current = res.nextJwtToken - // eslint-disable-next-line require-atomic-updates - cursor.current = res.cursor - - retryIntervalMs.current = res.retryIntervalMs - - return {retryIntervalMs: retryIntervalMs.current} - }, []) - - useSelfAdjustingInterval(performPollCallback) - - return {appLogOutputs, errors} -} diff --git a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/useSelfAdjustingInterval.test.tsx b/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/useSelfAdjustingInterval.test.tsx deleted file mode 100644 index 1beaa1d2b29..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/useSelfAdjustingInterval.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import {useSelfAdjustingInterval} from './useSelfAdjustingInterval.js' -import {describe, test, expect, vi, beforeEach, afterEach} from 'vitest' -import React from 'react' -import {render} from '@shopify/cli-kit/node/testing/ui' - -function renderHook(renderHookCallback: () => THookResult) { - const result: { - lastResult: THookResult | undefined - lastError: unknown | undefined - } = { - lastResult: undefined, - lastError: undefined, - } - - const MockComponent = () => { - try { - const hookResult = renderHookCallback() - result.lastResult = hookResult - // eslint-disable-next-line no-catch-all/no-catch-all - } catch (errorFromHook) { - result.lastError = errorFromHook - } - - return null - } - - const {unmount} = render() - - return { - lastResult: result.lastResult, - lastError: result.lastError, - unmount, - } -} - -describe('useSelfAdjustingInterval', () => { - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.clearAllTimers() - }) - - test('calls callback immediately and sets up next interval', async () => { - const callback = vi.fn(() => Promise.resolve({retryIntervalMs: 1000})) - - renderHook(() => useSelfAdjustingInterval(callback)) - - // Initial call at t=0 - await vi.advanceTimersByTimeAsync(0) - expect(callback).toHaveBeenCalledTimes(1) - - // Next call after retryIntervalMs - await vi.advanceTimersByTimeAsync(1000) - expect(callback).toHaveBeenCalledTimes(2) - }) - - test('adjusts interval based on callback return value', async () => { - const callback = vi - .fn() - .mockImplementationOnce(() => Promise.resolve({retryIntervalMs: 1000})) - .mockImplementationOnce(() => Promise.resolve({retryIntervalMs: 2000})) - .mockImplementationOnce(() => Promise.resolve({retryIntervalMs: null})) - renderHook(() => useSelfAdjustingInterval(callback)) - - // Initial call - await vi.advanceTimersByTimeAsync(0) - expect(callback).toHaveBeenCalledTimes(1) - - // Next call after 1000ms - await vi.advanceTimersByTimeAsync(1000) - expect(callback).toHaveBeenCalledTimes(2) - - // Next call after 2000ms - await vi.advanceTimersByTimeAsync(2000) - expect(callback).toHaveBeenCalledTimes(3) - - // Doesn't get called again because retryIntervalMs is null - await vi.advanceTimersByTimeAsync(2000) - expect(callback).toHaveBeenCalledTimes(3) - }) - - test('deals with the callback throwing an error', async () => { - const callback = vi.fn(() => Promise.reject(new Error('test error'))) - const {lastResult} = renderHook(() => useSelfAdjustingInterval(callback)) - - await vi.advanceTimersByTimeAsync(0) - expect(callback).toHaveBeenCalledTimes(1) - expect(lastResult).toBeUndefined() - }) - - test('cleans up timeout on unmount', async () => { - const callback = vi.fn(() => Promise.resolve({retryIntervalMs: 1000})) - - const {unmount} = renderHook(() => useSelfAdjustingInterval(callback)) - - // give it a moment to set up the interval - await vi.advanceTimersByTimeAsync(0) - expect(vi.getTimerCount()).toBe(1) - - // unmount and give it a moment to clean up the interval - unmount() - await vi.advanceTimersByTimeAsync(0) - - expect(vi.getTimerCount()).toBe(0) - }) -}) diff --git a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/useSelfAdjustingInterval.ts b/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/useSelfAdjustingInterval.ts deleted file mode 100644 index 114e6b54da0..00000000000 --- a/packages/app/src/cli/services/app-logs/logs-command/ui/components/hooks/useSelfAdjustingInterval.ts +++ /dev/null @@ -1,26 +0,0 @@ -import {useRef, useEffect} from 'react' - -export function useSelfAdjustingInterval(callback: () => Promise) { - const savedCallback = useRef(callback) - useEffect(() => { - savedCallback.current = callback - }, [callback]) - - useEffect(() => { - let id: NodeJS.Timeout - function tick() { - const ret = savedCallback.current() - ret - .then(({retryIntervalMs}) => { - if (retryIntervalMs) { - id = setTimeout(tick, retryIntervalMs) - } - }) - .catch(() => { - // stop interval on error/rejection - }) - } - id = setTimeout(tick, 0) - return () => id && clearTimeout(id) - }, []) -} diff --git a/packages/app/src/cli/services/app-logs/sources.test.ts b/packages/app/src/cli/services/app-logs/sources.test.ts deleted file mode 100644 index 2ba63e35d43..00000000000 --- a/packages/app/src/cli/services/app-logs/sources.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import {sources} from './sources.js' -import {sourcesForApp} from './utils.js' -import {testApp, testFunctionExtension, testUIExtension} from '../../models/app/app.test-data.js' -import {expect, test} from 'vitest' - -test('returns function sources in extension order', async () => { - const first = await testFunctionExtension({ - config: {...(await testFunctionExtension()).configuration, handle: 'first'}, - }) - const second = await testFunctionExtension({ - config: {...(await testFunctionExtension()).configuration, handle: 'second'}, - }) - const ui = await testUIExtension() - - const app = testApp({allExtensions: [first, ui, second]}) - const result = sources(app) - expect(result.map(({source}) => source)).toEqual(sourcesForApp(app)) - expect(result.map(({source}) => source)).toEqual(['extensions.first', 'extensions.second']) - expect(result[0]).toEqual({ - source: 'extensions.first', - namespace: 'extensions', - handle: 'first', - name: first.configuration.name, - type: first.type, - externalType: first.externalType, - humanName: first.humanName, - uid: first.uid, - directory: first.directory, - configurationPath: first.configurationPath, - configuration: first.configuration, - entrySourceFilePath: first.entrySourceFilePath, - outputPath: first.outputPath, - surface: first.surface, - features: first.features, - dependency: first.dependency, - }) -}) - -test('returns an empty collection without function extensions', () => { - expect(sources(testApp())).toEqual([]) -}) diff --git a/packages/app/src/cli/services/app-logs/sources.ts b/packages/app/src/cli/services/app-logs/sources.ts deleted file mode 100644 index f56b4248b9f..00000000000 --- a/packages/app/src/cli/services/app-logs/sources.ts +++ /dev/null @@ -1,25 +0,0 @@ -import {AppLogSourcesResult} from './sources/types.js' -import {AppInterface} from '../../models/app/app.js' - -export function sources(app: AppInterface): AppLogSourcesResult['sources'] { - return app.allExtensions - .filter((extension) => extension.isFunctionExtension) - .map((extension) => ({ - source: `extensions.${extension.configuration.handle}`, - namespace: 'extensions', - handle: extension.handle, - name: extension.name, - type: extension.type, - externalType: extension.externalType, - humanName: extension.humanName, - uid: extension.uid, - directory: extension.directory, - configurationPath: extension.configurationPath, - configuration: extension.configuration, - entrySourceFilePath: extension.entrySourceFilePath, - outputPath: extension.outputPath, - surface: extension.surface, - features: extension.features, - dependency: extension.dependency, - })) -} diff --git a/packages/app/src/cli/services/app-logs/sources/result.test.ts b/packages/app/src/cli/services/app-logs/sources/result.test.ts deleted file mode 100644 index 77f73b09070..00000000000 --- a/packages/app/src/cli/services/app-logs/sources/result.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {renderAppLogSourcesResult} from './result.js' -import {sources} from '../sources.js' -import {testApp, testFunctionExtension} from '../../../models/app/app.test-data.js' -import {outputResult, formatSection} from '@shopify/cli-kit/node/output' -import {describe, test, vi, expect} from 'vitest' - -vi.mock('@shopify/cli-kit/node/output') - -describe('sources', () => { - test('prints sources by namespace', async () => { - // Given - vi.mocked(formatSection).mockReturnValue('formatted section') - - // When - const extension = await testFunctionExtension() - const result = sources(testApp({allExtensions: [extension]}))[0]! - renderAppLogSourcesResult( - [ - {...result, source: 'extensions.source1'}, - {...result, source: 'extensions.source2'}, - ], - 'text', - ) - - // Then - expect(formatSection).toHaveBeenCalledWith('extensions', 'extensions.source1\nextensions.source2') - expect(outputResult).toHaveBeenCalledWith('formatted section') - }) -}) diff --git a/packages/app/src/cli/services/app-logs/sources/result.ts b/packages/app/src/cli/services/app-logs/sources/result.ts deleted file mode 100644 index c2cb94814a5..00000000000 --- a/packages/app/src/cli/services/app-logs/sources/result.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {appLogSourcesJsonOutputSchema, type AppLogSourcesResult} from './types.js' -import {formatSection, outputResult} from '@shopify/cli-kit/node/output' - -export function renderAppLogSourcesResult(result: AppLogSourcesResult['sources'], format: 'json' | 'text'): void { - if (format === 'json') { - outputResult(appLogSourcesJsonOutputSchema.encode({sources: result})) - return - } - - const sourcesByNamespace = new Map() - result.forEach(({source}) => { - const tokens = source.split('.') - - if (tokens.length >= 2) { - const sourceNamespace = tokens[0]! - - if (!sourcesByNamespace.has(sourceNamespace)) { - sourcesByNamespace.set(sourceNamespace, []) - } - - sourcesByNamespace.set(sourceNamespace, [...sourcesByNamespace.get(sourceNamespace)!, source]) - } - }) - - for (const [namespace, sources] of sourcesByNamespace) { - outputResult(formatSection(namespace, sources.join('\n'))) - } -} diff --git a/packages/app/src/cli/services/app-logs/sources/types.test.ts b/packages/app/src/cli/services/app-logs/sources/types.test.ts deleted file mode 100644 index 6f393e0dc57..00000000000 --- a/packages/app/src/cli/services/app-logs/sources/types.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import {appLogSourcesJsonOutputSchema} from './types.js' -import {sources} from '../sources.js' -import {testApp, testFunctionExtension} from '../../../models/app/app.test-data.js' -import {expect, test} from 'vitest' - -test('encodes source metadata and preserves dynamic configuration fields', async () => { - const extension = await testFunctionExtension() - const result = sources(testApp({allExtensions: [extension]})) - expect(JSON.parse(appLogSourcesJsonOutputSchema.encode({sources: result}))).toEqual({ - sources: JSON.parse(JSON.stringify(result)), - }) - expect(JSON.parse(appLogSourcesJsonOutputSchema.encode({sources: []}))).toEqual({sources: []}) -}) - -test.each([{value: {}}, {value: [1]}, {value: [null]}, {value: ['extensions.example']}])( - 'rejects invalid sources: $value', - ({value}) => { - expect(() => appLogSourcesJsonOutputSchema.validate(value)).toThrow() - }, -) - -test('rejects malformed metadata and omits absent optional fields', async () => { - const result = sources(testApp({allExtensions: [await testFunctionExtension()]}))[0]! - expect(() => appLogSourcesJsonOutputSchema.validate({sources: [{...result, uid: null}]})).toThrow() - expect(() => appLogSourcesJsonOutputSchema.validate({sources: [{...result, features: [false]}]})).toThrow() - expect( - JSON.parse(appLogSourcesJsonOutputSchema.encode({sources: [{...result, dependency: undefined}]})).sources[0], - ).not.toHaveProperty('dependency') -}) diff --git a/packages/app/src/cli/services/app-logs/sources/types.ts b/packages/app/src/cli/services/app-logs/sources/types.ts deleted file mode 100644 index 24cf164178d..00000000000 --- a/packages/app/src/cli/services/app-logs/sources/types.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {defineJsonOutputSchema, type InferJsonOutputSchema} from '@shopify/cli-kit/node/json-output-schema' -import {zod} from '@shopify/cli-kit/node/schema' - -const sourceSchema = zod - .object({ - source: zod.string(), - namespace: zod.literal('extensions'), - handle: zod.string(), - name: zod.string(), - type: zod.string(), - externalType: zod.string(), - humanName: zod.string(), - uid: zod.string(), - directory: zod.string(), - configurationPath: zod.string(), - configuration: zod.record(zod.unknown()), - entrySourceFilePath: zod.string(), - outputPath: zod.string(), - surface: zod.string(), - features: zod.array(zod.string()), - dependency: zod.string().optional(), - }) - .strict() - -export const appLogSourcesJsonOutputSchema = defineJsonOutputSchema({ - name: 'AppLogSourcesResult', - schema: zod.object({sources: zod.array(sourceSchema)}).strict(), - definitions: {AppLogSource: sourceSchema}, -}) - -export type AppLogSourcesResult = InferJsonOutputSchema diff --git a/packages/app/src/cli/services/app-logs/types.ts b/packages/app/src/cli/services/app-logs/types.ts index 1afc5ea10cd..1e7bd7fee5c 100644 --- a/packages/app/src/cli/services/app-logs/types.ts +++ b/packages/app/src/cli/services/app-logs/types.ts @@ -1,11 +1,3 @@ -import {AppLogsSubscribeMutationVariables} from '../../api/graphql/app-management/generated/app-logs-subscribe.js' -import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' - -export interface SuccessResponse { - appLogs: AppLogData[] - cursor?: string -} - export interface ErrorResponse { errors: { status: number @@ -13,8 +5,6 @@ export interface ErrorResponse { }[] } -export type PollResponse = SuccessResponse | ErrorResponse - export interface AppLogData { shop_id: number api_client_id: number @@ -90,109 +80,3 @@ export class FunctionRunLog { this.inputQueryVariablesMetafieldKey = inputQueryVariablesMetafieldKey } } - -export class NetworkAccessResponseFromCacheLog { - cacheEntryEpochMs: number - cacheTtlMs: number - httpRequest: unknown - httpResponse: unknown - - constructor({ - cacheEntryEpochMs, - cacheTtlMs, - httpRequest, - httpResponse, - }: { - cacheEntryEpochMs: number - cacheTtlMs: number - httpRequest: unknown - httpResponse: unknown - }) { - this.cacheEntryEpochMs = cacheEntryEpochMs - this.cacheTtlMs = cacheTtlMs - this.httpRequest = httpRequest - this.httpResponse = httpResponse - } -} - -export enum BackgroundExecutionReason { - NoCachedResponse, - CacheAboutToExpire, - Unknown, -} - -export class NetworkAccessRequestExecutionInBackgroundLog { - reason: BackgroundExecutionReason - httpRequest: unknown - - constructor({reason, httpRequest}: {reason: BackgroundExecutionReason; httpRequest: unknown}) { - this.reason = reason - this.httpRequest = httpRequest - } -} - -export class NetworkAccessRequestExecutedLog { - attempt: number - connectTimeMs: number | null - writeReadTimeMs: number | null - httpRequest: unknown - httpResponse: unknown - error: string | null - - constructor({ - attempt, - connectTimeMs, - writeReadTimeMs, - httpRequest, - httpResponse, - error, - }: { - attempt: number - connectTimeMs: number | null - writeReadTimeMs: number | null - httpRequest: unknown - httpResponse: unknown - error: string | null - }) { - this.attempt = attempt - this.connectTimeMs = connectTimeMs - this.writeReadTimeMs = writeReadTimeMs - this.httpRequest = httpRequest - this.httpResponse = httpResponse - this.error = error - } -} - -export type AppLogPayload = - | FunctionRunLog - | NetworkAccessResponseFromCacheLog - | NetworkAccessRequestExecutionInBackgroundLog - | NetworkAccessRequestExecutedLog - -export interface SubscribeOptions { - developerPlatformClient: DeveloperPlatformClient - variables: AppLogsSubscribeMutationVariables -} - -export interface PollOptions { - jwtToken: string - cursor?: string - filters: PollFilters -} - -export interface PollFilters { - status: string | undefined - sources: string[] | undefined -} -export interface AppLogPrefix { - status: string - source: string - storeName: string - description: string - logTimestamp: string -} - -export interface AppLogOutput { - prefix: AppLogPrefix - appLog: AppLogPayload -} diff --git a/packages/app/src/cli/services/app-logs/utils.ts b/packages/app/src/cli/services/app-logs/utils.ts index 558995447e5..b3f71d8ab47 100644 --- a/packages/app/src/cli/services/app-logs/utils.ts +++ b/packages/app/src/cli/services/app-logs/utils.ts @@ -1,15 +1,6 @@ -import { - BackgroundExecutionReason, - FunctionRunLog, - NetworkAccessRequestExecutedLog, - NetworkAccessRequestExecutionInBackgroundLog, - NetworkAccessResponseFromCacheLog, - ErrorResponse, - AppLogData, -} from './types.js' +import {FunctionRunLog, ErrorResponse, AppLogData} from './types.js' import camelcaseKeys from './camelcase-keys.js' import {DeveloperPlatformClient} from '../../utilities/developer-platform-client.js' -import {AppInterface} from '../../models/app/app.js' import {AppLogsSubscribeMutationVariables} from '../../api/graphql/app-management/generated/app-logs-subscribe.js' import {outputDebug, outputWarn} from '@shopify/cli-kit/node/output' import {AbortError} from '@shopify/cli-kit/node/error' @@ -19,7 +10,7 @@ import {Writable} from 'stream' export const POLLING_INTERVAL_MS = 450 export const POLLING_ERROR_RETRY_INTERVAL_MS = 5 * 1000 -export const POLLING_THROTTLE_RETRY_INTERVAL_MS = 60 * 1000 +const POLLING_THROTTLE_RETRY_INTERVAL_MS = 60 * 1000 export const MAX_CONSECUTIVE_RESUBSCRIBE_FAILURES = 5 export const ONE_MILLION = 1000000 export const LOG_TYPE_FUNCTION_RUN = 'function_run' @@ -30,68 +21,6 @@ export const LOG_TYPE_REQUEST_EXECUTION = 'function_network_access.request_execu export const REQUEST_EXECUTION_IN_BACKGROUND_NO_CACHED_RESPONSE_REASON = 'no_cached_response' export const REQUEST_EXECUTION_IN_BACKGROUND_CACHE_ABOUT_TO_EXPIRE_REASON = 'cached_response_about_to_expire' -export function parseFunctionRunPayload(payload: string): FunctionRunLog { - const parsedPayload = JSON.parse(payload) - - const parsedIqvValue = - parsedPayload.input_query_variables_metafield_value && - parseJson(parsedPayload.input_query_variables_metafield_value) - - return new FunctionRunLog({ - export: parsedPayload.export, - input: parsedPayload.input, - inputBytes: parsedPayload.input_bytes, - output: parsedPayload.output, - outputBytes: parsedPayload.output_bytes, - logs: parsedPayload.logs, - functionId: parsedPayload.function_id, - fuelConsumed: parsedPayload.fuel_consumed, - target: parsedPayload.target, - errorMessage: parsedPayload.error_message, - errorType: parsedPayload.error_type, - inputQueryVariablesMetafieldValue: parsedIqvValue, - inputQueryVariablesMetafieldNamespace: parsedPayload.input_query_variables_metafield_namespace, - inputQueryVariablesMetafieldKey: parsedPayload.input_query_variables_metafield_key, - }) -} - -export function parseNetworkAccessResponseFromCachePayload(payload: string): NetworkAccessResponseFromCacheLog { - const parsedPayload = JSON.parse(payload) - return new NetworkAccessResponseFromCacheLog({ - cacheEntryEpochMs: parsedPayload.cache_entry_epoch_ms, - cacheTtlMs: parsedPayload.cache_ttl_ms, - httpRequest: parsedPayload.http_request, - httpResponse: parsedPayload.http_response, - }) -} - -const reasonStringToEnum: {[key: string]: BackgroundExecutionReason} = { - no_cached_response: BackgroundExecutionReason.NoCachedResponse, - cached_response_about_to_expire: BackgroundExecutionReason.CacheAboutToExpire, -} - -export function parseNetworkAccessRequestExecutionInBackgroundPayload( - payload: string, -): NetworkAccessRequestExecutionInBackgroundLog { - const parsedPayload = JSON.parse(payload) - return new NetworkAccessRequestExecutionInBackgroundLog({ - reason: reasonStringToEnum[parsedPayload.reason] ?? BackgroundExecutionReason.Unknown, - httpRequest: parsedPayload.http_request, - }) -} - -export function parseNetworkAccessRequestExecutedPayload(payload: string): NetworkAccessRequestExecutedLog { - const parsedPayload = JSON.parse(payload) - return new NetworkAccessRequestExecutedLog({ - attempt: parsedPayload.attempt, - connectTimeMs: parsedPayload.connect_time_ms ?? null, - writeReadTimeMs: parsedPayload.write_read_time_ms ?? null, - httpRequest: parsedPayload.http_request, - httpResponse: parsedPayload.http_response ?? null, - error: parsedPayload.error ?? null, - }) -} - interface FetchAppLogsErrorOptions { response: ErrorResponse onThrottle: (retryIntervalMs: number) => void @@ -145,12 +74,6 @@ export const handleFetchAppLogsError = async ( return {retryIntervalMs, nextJwtToken, resubscribeResult} } -export function sourcesForApp(app: AppInterface): string[] { - return app.allExtensions.flatMap((extension) => { - return extension.isFunctionExtension ? [`extensions.${extension.configuration.handle}`] : [] - }) -} - export const toFormattedAppLogJson = ({ appLog, appLogPayload, @@ -188,23 +111,6 @@ export const toFormattedAppLogJson = ({ } } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const parseAppLogPayload = (payload: string, logType: string): any => { - const parsedPayload = camelcaseKeys(JSON.parse(payload)) - - if (logType === LOG_TYPE_FUNCTION_RUN) { - return new FunctionRunLog(parsedPayload) - } else if (logType === LOG_TYPE_RESPONSE_FROM_CACHE) { - return new NetworkAccessResponseFromCacheLog(parsedPayload) - } else if (logType === LOG_TYPE_REQUEST_EXECUTION_IN_BACKGROUND) { - return new NetworkAccessRequestExecutionInBackgroundLog(parsedPayload) - } else if (logType === LOG_TYPE_REQUEST_EXECUTION) { - return new NetworkAccessRequestExecutedLog(parsedPayload) - } else { - return parsedPayload - } -} - export const subscribeToAppLogs = async ( developerPlatformClient: DeveloperPlatformClient, variables: AppLogsSubscribeMutationVariables, diff --git a/packages/app/src/cli/services/context.ts b/packages/app/src/cli/services/context.ts index 37d39243ef2..bdf38016776 100644 --- a/packages/app/src/cli/services/context.ts +++ b/packages/app/src/cli/services/context.ts @@ -27,11 +27,7 @@ export const InvalidApiKeyErrorMessage = (apiKey: string) => { } } -export const resetHelpMessage = [ - 'You can pass', - {command: '--reset'}, - 'to your command to reset your app configuration.', -] +const resetHelpMessage = ['You can pass', {command: '--reset'}, 'to your command to reset your app configuration.'] const appNotFoundHelpMessage = (accountIdentifier: string, isOrg: boolean, offerReset: boolean) => [ { diff --git a/packages/app/src/cli/services/logs.test.ts b/packages/app/src/cli/services/logs.test.ts deleted file mode 100644 index 6c9ff5e59e3..00000000000 --- a/packages/app/src/cli/services/logs.test.ts +++ /dev/null @@ -1,262 +0,0 @@ -import {logs} from './logs.js' -import {subscribeToAppLogs, sourcesForApp} from './app-logs/utils.js' -import * as renderLogs from './app-logs/logs-command/ui.js' -import * as renderJsonLogs from './app-logs/logs-command/render-json-logs.js' -import {fetchStore} from './dev/fetch.js' -import { - testAppLinked, - testDeveloperPlatformClient, - testOrganization, - testOrganizationApp, - testOrganizationStore, -} from '../models/app/app.test-data.js' -import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' -import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' -import {AbortError} from '@shopify/cli-kit/node/error' -import {describe, test, vi, expect, beforeEach} from 'vitest' -import {renderInfo} from '@shopify/cli-kit/node/ui' - -vi.mock('./dev/fetch.js') -vi.mock('./app-logs/logs-command/ui.js') -vi.mock('./app-logs/logs-command/render-json-logs.js') -vi.mock('./app-logs/utils.js') -vi.mock('@shopify/cli-kit/node/output') -vi.mock('@shopify/cli-kit/node/ui') - -const app = testAppLinked() -const remoteApp = testOrganizationApp() -const organization = testOrganization() -let developerPlatformClient: DeveloperPlatformClient -const primaryStore = testOrganizationStore({shopId: '1', shopDomain: 'store-fqdn'}) - -describe('logs', () => { - beforeEach(() => { - developerPlatformClient = testDeveloperPlatformClient() - }) - - test('should call json handler when format is json', async () => { - // Given - const sources = ['extensions.source'] - await setupDevContext(sources) - const spy = vi.spyOn(renderJsonLogs, 'renderJsonLogs') - - // When - await logs({ - format: 'json', - app, - remoteApp, - organization, - developerPlatformClient, - primaryStore, - storeFqdns: ['store-fqdn'], - sources, - status: 'status', - }) - - // Then - expect(outputInfo).toHaveBeenCalledWith('Waiting for app logs...\n') - expect(spy).toHaveBeenCalled() - }) - - test('should call text handler when format is text', async () => { - // Given - const sources = ['extensions.source'] - await setupDevContext(sources) - const spy = vi.spyOn(renderLogs, 'renderLogs') - - // When - await logs({ - format: 'text', - app, - remoteApp, - organization, - developerPlatformClient, - primaryStore, - storeFqdns: ['store-fqdn'], - sources, - status: 'status', - }) - - // Then - expect(outputInfo).toHaveBeenCalledWith('Waiting for app logs...\n') - expect(spy).toHaveBeenCalled() - }) - - test('should raise error when app has no valid sources', async () => { - // Given - await setupDevContext([]) - - // When - await expect(() => { - return logs({ - format: 'text', - app, - remoteApp, - organization, - developerPlatformClient, - primaryStore, - storeFqdns: ['store-fqdn'], - sources: ['extensions.source'], - status: 'status', - }) - }).rejects.toThrowError( - new AbortError( - 'This app has no log sources. Learn more about app logs at https://shopify.dev/docs/api/shopify-cli/app/app-logs', - ), - ) - }) - - test('should raise error when sources in filter do not match valid sources', async () => { - // Given - await setupDevContext(['realSource', 'anotherSource']) - const spy = vi.spyOn(renderLogs, 'renderLogs') - - // When - await expect(() => { - return logs({ - format: 'text', - app, - remoteApp, - organization, - developerPlatformClient, - primaryStore, - storeFqdns: ['store-fqdn'], - sources: ['extensions.realSource', 'extensions.invalidSource'], - status: 'status', - }) - }).rejects.toThrowError( - new AbortError( - 'Invalid sources: extensions.invalidSource. Valid sources are: extensions.realSource, extensions.anotherSource', - ), - ) - }) - - test('should load additional stores in JSON mode', async () => { - // Given - const sources = ['extensions.source'] - await setupDevContext(sources) - const spy = vi.spyOn(renderJsonLogs, 'renderJsonLogs') - - vi.mocked(fetchStore).mockResolvedValueOnce(testOrganizationStore({shopId: '2', shopDomain: 'other-fqdn'})) - - // When - await logs({ - format: 'json', - app, - remoteApp, - organization, - developerPlatformClient, - primaryStore, - storeFqdns: ['store-fqdn', 'other-fqdn'], - sources, - status: 'status', - }) - - // Then - const expectedStoreMap = new Map() - expectedStoreMap.set('1', 'store-fqdn') - expectedStoreMap.set('2', 'other-fqdn') - expect(outputResult).toHaveBeenCalledWith('{"subscribedToStores":["store-fqdn","other-fqdn"]}') - expect(outputInfo).toHaveBeenCalledWith('Waiting for app logs...\n') - expect(spy).toHaveBeenCalledWith({ - options: { - developerPlatformClient: expect.anything(), - variables: { - apiKey: expect.anything(), - shopIds: [1, 2], - }, - }, - pollOptions: expect.anything(), - storeNameById: expectedStoreMap, - organizationId: organization.id, - }) - }) - - test('should load additional stores in TTY mode', async () => { - // Given - const sources = ['extensions.source'] - await setupDevContext(sources) - const spy = vi.spyOn(renderLogs, 'renderLogs') - - vi.mocked(fetchStore).mockResolvedValueOnce(testOrganizationStore({shopId: '2', shopDomain: 'other-fqdn'})) - - // When - await logs({ - format: 'text', - app, - remoteApp, - organization, - developerPlatformClient, - primaryStore, - storeFqdns: ['store-fqdn', 'other-fqdn'], - sources, - status: 'status', - }) - - // Then - const expectedStoreMap = new Map() - expectedStoreMap.set('1', 'store-fqdn') - expectedStoreMap.set('2', 'other-fqdn') - expect(outputInfo).toHaveBeenCalledWith('Waiting for app logs...\n') - expect(spy).toHaveBeenCalledWith({ - options: { - developerPlatformClient: expect.anything(), - variables: { - apiKey: expect.anything(), - shopIds: [1, 2], - }, - }, - pollOptions: expect.anything(), - storeNameById: expectedStoreMap, - organizationId: organization.id, - }) - }) - - test.each(['text', 'json'] as const)('renders configuration info in %s mode', async (format) => { - // Given - const sources = ['extensions.source'] - await setupDevContext(sources) - vi.mocked(fetchStore).mockResolvedValueOnce(testOrganizationStore({shopId: '2', shopDomain: 'other-fqdn'})) - - // When - await logs({ - format, - app, - remoteApp, - organization, - developerPlatformClient, - primaryStore, - storeFqdns: ['store-fqdn', 'other-fqdn'], - sources, - status: 'status', - }) - - // Then - expect(renderInfo).toHaveBeenCalledWith({ - body: [ - { - list: { - items: [ - 'Org: org1', - 'App: app1', - 'Dev store: store-fqdn', - 'Dev store: other-fqdn', - ], - }, - }, - '\n', - 'You can pass', - { - command: '--reset', - }, - 'to your command to reset your app configuration.', - ], - headline: 'Using shopify.app.toml for default values:', - }) - }) -}) - -async function setupDevContext(handles: string[]) { - vi.mocked(sourcesForApp).mockReturnValue(handles) - vi.mocked(subscribeToAppLogs).mockResolvedValue('jwt-token') -} diff --git a/packages/app/src/cli/services/logs.ts b/packages/app/src/cli/services/logs.ts deleted file mode 100644 index d12537292b0..00000000000 --- a/packages/app/src/cli/services/logs.ts +++ /dev/null @@ -1,128 +0,0 @@ -import {resetHelpMessage} from './context.js' -import {renderLogs} from './app-logs/logs-command/ui.js' -import {subscribeToAppLogs, sourcesForApp} from './app-logs/utils.js' -import {renderJsonLogs} from './app-logs/logs-command/render-json-logs.js' -import {fetchStore} from './dev/fetch.js' -import {formatConfigInfoBody} from './format-config-info-body.js' -import {AppLinkedInterface} from '../models/app/app.js' -import {getAppConfigurationFileName} from '../models/app/loader.js' -import {DeveloperPlatformClient} from '../utilities/developer-platform-client.js' -import {Organization, OrganizationApp, OrganizationStore} from '../models/organization.js' -import {AbortError} from '@shopify/cli-kit/node/error' -import {outputInfo, outputResult} from '@shopify/cli-kit/node/output' -import {renderInfo} from '@shopify/cli-kit/node/ui' -import {basename} from '@shopify/cli-kit/node/path' - -export type Format = 'json' | 'text' - -interface LogsOptions { - app: AppLinkedInterface - remoteApp: OrganizationApp - organization: Organization - developerPlatformClient: DeveloperPlatformClient - primaryStore: OrganizationStore - storeFqdns?: string[] - sources?: string[] - status?: string - format: Format -} - -export async function logs(commandOptions: LogsOptions) { - const {app, remoteApp, developerPlatformClient} = commandOptions - const logsConfig = await prepareForLogs(commandOptions) - - const validSources = sourcesForApp(app) - - if (validSources.length === 0) { - throw new AbortError( - `This app has no log sources. Learn more about app logs at https://shopify.dev/docs/api/shopify-cli/app/app-logs`, - ) - } - - if (commandOptions.sources) { - const invalidSources = commandOptions.sources.filter((source) => !validSources.includes(source)) - if (invalidSources.length) { - throw new AbortError( - `Invalid sources: ${invalidSources.join(', ')}. Valid sources are: ${validSources.join(', ')}`, - ) - } - } - - const variables = { - shopIds: logsConfig.storeIds.map(Number), - apiKey: remoteApp.apiKey, - } - - const jwtToken = await subscribeToAppLogs(developerPlatformClient, variables, commandOptions.organization.id) - - const filters = { - status: commandOptions.status, - sources: commandOptions.sources, - } - - const pollOptions = { - jwtToken, - filters, - } - - if (commandOptions.format === 'json') { - outputResult(JSON.stringify({subscribedToStores: commandOptions.storeFqdns})) - } - outputInfo('Waiting for app logs...\n') - const render = commandOptions.format === 'json' ? renderJsonLogs : renderLogs - await render({ - options: { - variables, - developerPlatformClient, - }, - pollOptions, - storeNameById: logsConfig.storeNameById, - organizationId: commandOptions.organization.id, - }) -} - -async function prepareForLogs(commandOptions: LogsOptions): Promise<{ - storeIds: string[] - storeNameById: Map -}> { - const {app, remoteApp, developerPlatformClient, primaryStore, organization} = commandOptions - - const configFile = basename(app.configPath) - renderAppLogsConfigInfo( - remoteApp.title, - primaryStore.shopDomain, - commandOptions.storeFqdns, - configFile, - organization.businessName, - ) - const storeNameById = new Map() - storeNameById.set(primaryStore.shopId, primaryStore.shopDomain) - if (commandOptions.storeFqdns && commandOptions.storeFqdns.length > 1) { - await Promise.all( - commandOptions.storeFqdns?.slice(1).map(async (storeFqdn) => { - const store = await fetchStore(organization, storeFqdn, developerPlatformClient) - storeNameById.set(store.shopId, storeFqdn) - }), - ) - } - const storeIds = Array.from(storeNameById.keys()) - - return {storeIds, storeNameById} -} - -function renderAppLogsConfigInfo( - appName: string, - storeFqdn: string, - storeFqdns?: string[], - configFile?: string, - org?: string, -) { - const devStores = storeFqdns && storeFqdns.length > 0 ? storeFqdns : [storeFqdn] - - const fileName = configFile ? getAppConfigurationFileName(configFile) : undefined - - renderInfo({ - headline: configFile ? `Using ${fileName} for default values:` : 'Using these settings:', - body: formatConfigInfoBody({appName, org, devStores, messages: [resetHelpMessage]}), - }) -} diff --git a/packages/cli/README.md b/packages/cli/README.md index 1bdcda90476..876b1cee62e 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -26,8 +26,6 @@ * [`shopify app import dashboard-extensions`](#shopify-app-import-dashboard-extensions) * [`shopify app info`](#shopify-app-info) * [`shopify app init`](#shopify-app-init) -* [`shopify app logs`](#shopify-app-logs) -* [`shopify app logs sources`](#shopify-app-logs-sources) * [`shopify app release --version `](#shopify-app-release---version-version) * [`shopify app security check`](#shopify-app-security-check) * [`shopify app security clean`](#shopify-app-security-clean) @@ -5158,242 +5156,6 @@ EXAMPLES $ shopify app init --client-id 123 --template none ``` -## `shopify app logs` - -Stream detailed logs for your Shopify app. - -``` -USAGE - $ shopify app logs [--auth-alias ] [--client-id | -c ] [-j] [--json-schema] - [--no-color] [--no-input] [--path ] [--reset | ] [--source ...] [--status success|failure] [-s - ...] [--verbose] - -FLAGS - -c, --config= - The name of the app configuration. - [env: SHOPIFY_FLAG_APP_CONFIG] - - -j, --json - Output the result as JSON. Automatically disables color output. - [env: SHOPIFY_FLAG_JSON] - - -s, --store=... - Store URL. Must be an existing development or Shopify Plus sandbox store. - [env: SHOPIFY_FLAG_STORE] - - --auth-alias= - Alias of the Shopify account to use for authentication. - [env: SHOPIFY_FLAG_AUTH_ALIAS] - - --client-id= - The Client ID of your app. - [env: SHOPIFY_FLAG_CLIENT_ID] - - --json-schema - Print the command's JSON schemas. - [env: SHOPIFY_FLAG_JSON_SCHEMA] - - --no-color - Disable color output. - [env: SHOPIFY_FLAG_NO_COLOR] - - --no-input - Disable interactive prompts and browser authentication. - [env: SHOPIFY_FLAG_NO_INPUT] - - --path= - The path to your app directory. - [env: SHOPIFY_FLAG_PATH] - - --reset - Reset all your settings. - [env: SHOPIFY_FLAG_RESET] - - --source=... - Filters output to the specified log source. - [env: SHOPIFY_FLAG_SOURCE] - - --status=