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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/profile-function-runs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@shopify/app': minor
---

Add WebAssembly profiling to `app function run`
11 changes: 10 additions & 1 deletion docs-shopify.dev/generated/generated_docs_data_v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Name of the WebAssembly export to invoke.\n * @environment SHOPIFY_FLAG_EXPORT\n */\n '-e, --export <value>'?: 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 <value>'?: 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 <value>'?: 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 <value>'?: string\n\n /**\n * The Client ID of your app.\n * @environment SHOPIFY_FLAG_CLIENT_ID\n */\n '--client-id <value>'?: string\n\n /**\n * The name of the app configuration.\n * @environment SHOPIFY_FLAG_APP_CONFIG\n */\n '-c, --config <value>'?: string\n\n /**\n * Name of the WebAssembly export to invoke.\n * @environment SHOPIFY_FLAG_EXPORT\n */\n '-e, --export <value>'?: 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 <value>'?: 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 <value>'?: 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": {
Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/cli/commands/app/function/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppUnlinkedCommandOutput> {
Expand Down Expand Up @@ -93,6 +98,7 @@ export default class FunctionRun extends AppUnlinkedCommand {
stdin: 'inherit',
schemaPath,
queryPath,
profile: flags.profile,
})

return {app}
Expand Down
95 changes: 94 additions & 1 deletion packages/app/src/cli/services/function/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof import('./binaries.js')>()
return {
Expand Down Expand Up @@ -42,6 +45,7 @@ describe('runFunction', () => {
stderr: new Writable(),
schemaPath: 'schemaPath',
queryPath: 'src/queryPath',
profile: true,
}

// When
Expand All @@ -58,6 +62,7 @@ describe('runFunction', () => {
'--export',
options.export,
'--json',
'--profile',
'--schema-path',
options.schemaPath,
'--query-path',
Expand Down Expand Up @@ -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 <unknown> 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()
})
})
})
47 changes: 47 additions & 0 deletions packages/app/src/cli/services/function/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -14,6 +16,7 @@ interface FunctionRunnerOptions {
json?: boolean
schemaPath?: string
queryPath?: string
profile?: boolean
stdin?: Readable | 'inherit'
stdout?: Writable | 'inherit'
stderr?: Writable | 'inherit'
Expand All @@ -34,6 +37,44 @@ function getFunctionPath(ext: ExtensionInstance<FunctionConfigType>) {
return ext.outputPath
}

async function warnIfProfileWillNotContainFunctionNames(
ext: ExtensionInstance<FunctionConfigType>,
functionPath: string,
): Promise<void> {
try {
if (!(await fileExists(functionPath))) return

const moduleBytes = readFileSync(functionPath) as Uint8Array<ArrayBuffer>
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 <unknown> 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

Expand All @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1136,7 +1136,7 @@ Run a function locally for testing.
```
USAGE
$ shopify app function run [--auth-alias <value>] [--client-id <value> | -c <value>] [-e <value>] [-i <value>] [-j]
[--no-color] [--path <value>] [--reset | ] [--verbose]
[--no-color] [--path <value>] [--profile] [--reset | ] [--verbose]

FLAGS
-c, --config=<value>
Expand Down Expand Up @@ -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]
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
Loading