From c55bb759f6287cfbe528197b8807ee183aff2871 Mon Sep 17 00:00:00 2001 From: David Cameron Date: Fri, 31 Jul 2026 12:14:34 -0400 Subject: [PATCH] feat(app): add WebAssembly profiling to function run Expose Function Runner profiling through a --profile flag and warn when profiles will not contain function names. Keep name-section inspection best-effort so profiling cannot prevent the function from running. --- .changeset/profile-function-runs.md | 5 + .../generated/generated_docs_data_v2.json | 11 ++- .../app/src/cli/commands/app/function/run.ts | 6 ++ .../src/cli/services/function/runner.test.ts | 95 ++++++++++++++++++- .../app/src/cli/services/function/runner.ts | 47 +++++++++ packages/cli/README.md | 6 +- packages/cli/oclif.manifest.json | 7 ++ 7 files changed, 174 insertions(+), 3 deletions(-) create mode 100644 .changeset/profile-function-runs.md diff --git a/.changeset/profile-function-runs.md b/.changeset/profile-function-runs.md new file mode 100644 index 00000000000..4950a0d1096 --- /dev/null +++ b/.changeset/profile-function-runs.md @@ -0,0 +1,5 @@ +--- +'@shopify/app': minor +--- + +Add WebAssembly profiling to `app function run` diff --git a/docs-shopify.dev/generated/generated_docs_data_v2.json b/docs-shopify.dev/generated/generated_docs_data_v2.json index af5f3e73590..dbb42324f45 100644 --- a/docs-shopify.dev/generated/generated_docs_data_v2.json +++ b/docs-shopify.dev/generated/generated_docs_data_v2.json @@ -1731,6 +1731,15 @@ "isOptional": true, "environmentValue": "SHOPIFY_FLAG_PATH" }, + { + "filePath": "docs-shopify.dev/commands/interfaces/app-function-run.interface.ts", + "syntaxKind": "PropertySignature", + "name": "--profile", + "value": "''", + "description": "Generate a WebAssembly performance profile for the function run. The profile can be viewed in Speedscope.", + "isOptional": true, + "environmentValue": "SHOPIFY_FLAG_PROFILE" + }, { "filePath": "docs-shopify.dev/commands/interfaces/app-function-run.interface.ts", "syntaxKind": "PropertySignature", @@ -1786,7 +1795,7 @@ "environmentValue": "SHOPIFY_FLAG_JSON" } ], - "value": "export interface appfunctionrun {\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 * Name of the WebAssembly export to invoke.\n * @environment SHOPIFY_FLAG_EXPORT\n */\n '-e, --export '?: string\n\n /**\n * The input JSON to pass to the function. If omitted, standard input is used.\n * @environment SHOPIFY_FLAG_INPUT\n */\n '-i, --input '?: 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 * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your function 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.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" + "value": "export interface appfunctionrun {\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 * Name of the WebAssembly export to invoke.\n * @environment SHOPIFY_FLAG_EXPORT\n */\n '-e, --export '?: string\n\n /**\n * The input JSON to pass to the function. If omitted, standard input is used.\n * @environment SHOPIFY_FLAG_INPUT\n */\n '-i, --input '?: 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 * Disable color output.\n * @environment SHOPIFY_FLAG_NO_COLOR\n */\n '--no-color'?: ''\n\n /**\n * The path to your function directory.\n * @environment SHOPIFY_FLAG_PATH\n */\n '--path '?: string\n\n /**\n * Generate a WebAssembly performance profile for the function run. The profile can be viewed in Speedscope.\n * @environment SHOPIFY_FLAG_PROFILE\n */\n '--profile'?: ''\n\n /**\n * Reset all your settings.\n * @environment SHOPIFY_FLAG_RESET\n */\n '--reset'?: ''\n\n /**\n * Increase the verbosity of the output.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}" } }, "appfunctionschema": { diff --git a/packages/app/src/cli/commands/app/function/run.ts b/packages/app/src/cli/commands/app/function/run.ts index 3f3bb602bb3..1eaa25a5022 100644 --- a/packages/app/src/cli/commands/app/function/run.ts +++ b/packages/app/src/cli/commands/app/function/run.ts @@ -33,6 +33,11 @@ export default class FunctionRun extends AppUnlinkedCommand { description: 'Name of the WebAssembly export to invoke.', env: 'SHOPIFY_FLAG_EXPORT', }), + profile: Flags.boolean({ + description: + 'Generate a WebAssembly performance profile for the function run. The profile can be viewed in Speedscope.', + env: 'SHOPIFY_FLAG_PROFILE', + }), } public async run(): Promise { @@ -93,6 +98,7 @@ export default class FunctionRun extends AppUnlinkedCommand { stdin: 'inherit', schemaPath, queryPath, + profile: flags.profile, }) return {app} diff --git a/packages/app/src/cli/services/function/runner.test.ts b/packages/app/src/cli/services/function/runner.test.ts index 6f32757e3af..8e6b5697f5a 100644 --- a/packages/app/src/cli/services/function/runner.test.ts +++ b/packages/app/src/cli/services/function/runner.test.ts @@ -3,10 +3,13 @@ import {functionRunnerBinary, downloadBinary} from './binaries.js' import {testFunctionExtension} from '../../models/app/app.test-data.js' import {describe, test, vi, expect} from 'vitest' import {exec} from '@shopify/cli-kit/node/system' -import {joinPath} from '@shopify/cli-kit/node/path' +import {dirname, joinPath} from '@shopify/cli-kit/node/path' +import {inTemporaryDirectory, mkdir, writeFile} from '@shopify/cli-kit/node/fs' +import {renderWarning} from '@shopify/cli-kit/node/ui' import {Readable, Writable} from 'stream' vi.mock('@shopify/cli-kit/node/system') +vi.mock('@shopify/cli-kit/node/ui') vi.mock('./binaries.js', async (importOriginal) => { const original = await importOriginal() return { @@ -42,6 +45,7 @@ describe('runFunction', () => { stderr: new Writable(), schemaPath: 'schemaPath', queryPath: 'src/queryPath', + profile: true, } // When @@ -58,6 +62,7 @@ describe('runFunction', () => { '--export', options.export, '--json', + '--profile', '--schema-path', options.schemaPath, '--query-path', @@ -89,4 +94,92 @@ describe('runFunction', () => { expect.objectContaining({cwd: functionExtension.directory}), ) }) + + test('warns when profiling a non-JavaScript function without function names', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const functionExtension = await testFunctionExtension({dir: tmpDir}) + await mkdir(dirname(functionExtension.outputPath)) + await writeFile(functionExtension.outputPath, Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])) + + // When + await runFunction({functionExtension, profile: true}) + + // Then + expect(renderWarning).toHaveBeenCalledWith({ + headline: "The profile won't contain names for your function.", + body: [ + "The built WebAssembly module doesn't contain a function name section. The default wasm-opt step removes this section, and the function compiler can also omit it. To preserve function names, set ", + {userInput: 'wasm_opt = false'}, + ' under ', + {userInput: '[extensions.build]'}, + ' in shopify.extension.toml, configure the compiler to emit function names, and rebuild the function.', + ], + }) + }) + }) + + test('warns that JavaScript function names are unavailable regardless of wasm-opt', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const functionExtension = await testFunctionExtension({ + dir: tmpDir, + entryPath: joinPath(tmpDir, 'src/index.ts'), + }) + const shopifyFunctionDirectory = joinPath(tmpDir, 'node_modules/@shopify/shopify_function') + await mkdir(shopifyFunctionDirectory) + await writeFile(joinPath(shopifyFunctionDirectory, 'package.json'), JSON.stringify({version: '2.0.0'})) + await mkdir(dirname(functionExtension.outputPath)) + await writeFile(functionExtension.outputPath, Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])) + + // When + await runFunction({functionExtension, profile: true}) + + // Then + expect(renderWarning).toHaveBeenCalledWith({ + headline: "The profile won't contain names for your function.", + body: "JavaScript functions built with Javy don't include a WebAssembly function name section, regardless of the wasm_opt setting. Function names will appear as in the profile.", + }) + }) + }) + + test('does not warn when profiling a function with function names', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const functionExtension = await testFunctionExtension({dir: tmpDir}) + await mkdir(dirname(functionExtension.outputPath)) + // This is the binary form of `(module (func $test))`, including its optional name section. + const moduleWithNamedFunction = Buffer.from([ + 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x04, 0x01, 0x60, 0x00, 0x00, 0x03, 0x02, 0x01, 0x00, + 0x0a, 0x04, 0x01, 0x02, 0x00, 0x0b, 0x00, 0x0e, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x01, 0x07, 0x01, 0x00, 0x04, + 0x74, 0x65, 0x73, 0x74, + ]) + await writeFile(functionExtension.outputPath, moduleWithNamedFunction) + + // When + await runFunction({functionExtension, profile: true}) + + // Then + expect(renderWarning).not.toHaveBeenCalled() + }) + }) + + test('runs the function when inspecting function names fails', async () => { + await inTemporaryDirectory(async (tmpDir) => { + // Given + const functionExtension = await testFunctionExtension({dir: tmpDir}) + await mkdir(dirname(functionExtension.outputPath)) + await writeFile(functionExtension.outputPath, Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])) + vi.spyOn(WebAssembly, 'validate').mockImplementationOnce(() => { + throw new Error('Could not inspect module') + }) + + // When + await runFunction({functionExtension, profile: true}) + + // Then + expect(exec).toHaveBeenCalled() + expect(renderWarning).not.toHaveBeenCalled() + }) + }) }) diff --git a/packages/app/src/cli/services/function/runner.ts b/packages/app/src/cli/services/function/runner.ts index 29336bc6069..bf1516cdd1d 100644 --- a/packages/app/src/cli/services/function/runner.ts +++ b/packages/app/src/cli/services/function/runner.ts @@ -4,6 +4,8 @@ import {ExtensionInstance} from '../../models/extensions/extension-instance.js' import {FunctionConfigType} from '../../models/extensions/specifications/function.js' import {exec} from '@shopify/cli-kit/node/system' import {joinPath} from '@shopify/cli-kit/node/path' +import {fileExists, readFileSync} from '@shopify/cli-kit/node/fs' +import {renderWarning} from '@shopify/cli-kit/node/ui' import {Readable, Writable} from 'stream' interface FunctionRunnerOptions { @@ -14,6 +16,7 @@ interface FunctionRunnerOptions { json?: boolean schemaPath?: string queryPath?: string + profile?: boolean stdin?: Readable | 'inherit' stdout?: Writable | 'inherit' stderr?: Writable | 'inherit' @@ -34,6 +37,44 @@ function getFunctionPath(ext: ExtensionInstance) { return ext.outputPath } +async function warnIfProfileWillNotContainFunctionNames( + ext: ExtensionInstance, + functionPath: string, +): Promise { + try { + if (!(await fileExists(functionPath))) return + + const moduleBytes = readFileSync(functionPath) as Uint8Array + if (!WebAssembly.validate(moduleBytes)) return + + const module = new WebAssembly.Module(moduleBytes) + const hasFunctionNames = WebAssembly.Module.customSections(module, 'name').length > 0 + if (hasFunctionNames) return + + if (ext.isJavaScript) { + renderWarning({ + headline: "The profile won't contain names for your function.", + body: "JavaScript functions built with Javy don't include a WebAssembly function name section, regardless of the wasm_opt setting. Function names will appear as in the profile.", + }) + return + } + + renderWarning({ + headline: "The profile won't contain names for your function.", + body: [ + "The built WebAssembly module doesn't contain a function name section. The default wasm-opt step removes this section, and the function compiler can also omit it. To preserve function names, set ", + {userInput: 'wasm_opt = false'}, + ' under ', + {userInput: '[extensions.build]'}, + ' in shopify.extension.toml, configure the compiler to emit function names, and rebuild the function.', + ], + }) + // eslint-disable-next-line no-catch-all/no-catch-all + } catch { + // Inspecting function names is best-effort and must never prevent the function from running. + } +} + export async function runFunction(options: FunctionRunnerOptions) { const ext = options.functionExtension @@ -50,12 +91,18 @@ export async function runFunction(options: FunctionRunnerOptions) { if (options.json) { args.push('--json') } + if (options.profile) { + args.push('--profile') + } if (options.schemaPath && options.queryPath) { args.push('--schema-path', options.schemaPath) args.push('--query-path', options.queryPath) } const functionPath = getFunctionPath(ext) + if (options.profile) { + await warnIfProfileWillNotContainFunctionNames(ext, functionPath) + } return exec(functionRunner.path, ['-f', functionPath, ...args], { cwd: options.functionExtension.directory, diff --git a/packages/cli/README.md b/packages/cli/README.md index a367597e622..deacd30a53b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1136,7 +1136,7 @@ Run a function locally for testing. ``` USAGE $ shopify app function run [--auth-alias ] [--client-id | -c ] [-e ] [-i ] [-j] - [--no-color] [--path ] [--reset | ] [--verbose] + [--no-color] [--path ] [--profile] [--reset | ] [--verbose] FLAGS -c, --config= @@ -1171,6 +1171,10 @@ FLAGS The path to your function directory. [env: SHOPIFY_FLAG_PATH] + --profile + Generate a WebAssembly performance profile for the function run. The profile can be viewed in Speedscope. + [env: SHOPIFY_FLAG_PROFILE] + --reset Reset all your settings. [env: SHOPIFY_FLAG_RESET] diff --git a/packages/cli/oclif.manifest.json b/packages/cli/oclif.manifest.json index 48b660f0982..444d9a2cd09 100644 --- a/packages/cli/oclif.manifest.json +++ b/packages/cli/oclif.manifest.json @@ -2018,6 +2018,13 @@ "noCacheDefault": true, "type": "option" }, + "profile": { + "allowNo": false, + "description": "Generate a WebAssembly performance profile for the function run. The profile can be viewed in Speedscope.", + "env": "SHOPIFY_FLAG_PROFILE", + "name": "profile", + "type": "boolean" + }, "reset": { "allowNo": false, "description": "Reset all your settings.",