Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add replay command #3617

Merged
merged 30 commits into from
Jun 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
5e7073d
add replay command
lopert Mar 28, 2024
b29c320
add replay command to list
lopert May 7, 2024
eaf1d4b
selectRun test
lopert May 8, 2024
4bf857e
lint replay.ts
lopert May 8, 2024
3e63e58
refresh oclif manifest
lopert May 8, 2024
ffd423b
generate docs
lopert May 8, 2024
73e6022
move readFunctionRunsDirectory to service layer
lopert May 9, 2024
4c0d817
clean up flags
lopert May 10, 2024
a460630
extract apiKey logic to it's own function
lopert May 30, 2024
2f450c2
rework replay to use new directory
lopert May 30, 2024
f8d7398
hide replay command and gate with envVar
lopert May 31, 2024
3dd4345
add identifier to log names
lopert May 31, 2024
80610f2
fixup! hide replay command and gate with envVar
lopert May 31, 2024
c9c7cb2
use FunctionRun as name
lopert May 31, 2024
f8ff50d
test fixes
lopert May 31, 2024
81d5944
replay test
lopert Jun 3, 2024
607dc3b
format timestamp to filename
lopert Jun 3, 2024
5b448d8
refresh manifest
lopert Jun 3, 2024
e40e7b4
remove old MinimalRunEvent
lopert Jun 3, 2024
262e606
update replay command descriptions
lopert Jun 3, 2024
2338558
use exec's input arg to pass in payload input
lopert Jun 4, 2024
923c335
description updates
lopert Jun 4, 2024
59db2dc
early return replay if envVar not set
lopert Jun 4, 2024
9c3421c
move selectFunctionRun prompt
lopert Jun 4, 2024
a103a8a
derive filename from date object in write
lopert Jun 5, 2024
d0380b4
identifierFromFilename should always return string
lopert Jun 5, 2024
1c4ee7b
docs
lopert Jun 5, 2024
dba2b8d
limit log lookup to 100
lopert Jun 5, 2024
8684e0b
throw when no logs
lopert Jun 5, 2024
a5364d1
use AbortError
lopert Jun 5, 2024
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ export interface appfunctionrun {
'-c, --config <value>'?: string

/**
* Name of the wasm export to invoke.
* Name of the WebAssembly export to invoke.
* @environment SHOPIFY_FLAG_EXPORT
*/
'-e, --export <value>'?: string
Expand Down
4 changes: 2 additions & 2 deletions docs-shopify.dev/generated/generated_docs_data.json
Original file line number Diff line number Diff line change
Expand Up @@ -859,7 +859,7 @@
"syntaxKind": "PropertySignature",
"name": "-e, --export <value>",
"value": "string",
"description": "Name of the wasm export to invoke.",
"description": "Name of the WebAssembly export to invoke.",
"isOptional": true,
"environmentValue": "SHOPIFY_FLAG_EXPORT"
},
Expand All @@ -882,7 +882,7 @@
"environmentValue": "SHOPIFY_FLAG_JSON"
}
],
"value": "export interface appfunctionrun {\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 wasm 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 * Log the run result as a JSON object.\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 * Increase the verbosity of the logs.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
"value": "export interface appfunctionrun {\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 * Log the run result as a JSON object.\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 * Increase the verbosity of the logs.\n * @environment SHOPIFY_FLAG_VERBOSE\n */\n '--verbose'?: ''\n}"
}
}
}
Expand Down
83 changes: 83 additions & 0 deletions packages/app/src/cli/commands/app/function/replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {functionFlags, inFunctionContext} from '../../../services/function/common.js'
import {replay} from '../../../services/function/replay.js'
import {appFlags} from '../../../flags.js'
import {showApiKeyDeprecationWarning} from '../../../prompts/deprecation-warnings.js'
import {environmentVariableNames} from '../../../constants.js'
import Command from '@shopify/cli-kit/node/base-command'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {Flags} from '@oclif/core'
import {getEnvironmentVariables} from '@shopify/cli-kit/node/environment'
import {isTruthy} from '@shopify/cli-kit/node/context/utilities'

export default class FunctionReplay extends Command {
static hidden = true
static summary = 'Replays a function run from an app log.'

static descriptionWithMarkdown = `Runs the function from your current directory for [testing purposes](https://shopify.dev/docs/apps/functions/testing-and-debugging). To learn how you can monitor and debug functions when errors occur, refer to [Shopify Functions error handling](https://shopify.dev/docs/api/functions/errors).`

static description = this.descriptionWithoutMarkdown()

static flags = {
...globalFlags,
...appFlags,
...functionFlags,
'api-key': Flags.string({
hidden: true,
description: "Application's API key",
env: 'SHOPIFY_FLAG_API_KEY',
exclusive: ['config'],
}),
'client-id': Flags.string({
hidden: false,
description: "Application's Client ID",
env: 'SHOPIFY_FLAG_CLIENT_ID',
exclusive: ['config'],
}),
export: Flags.string({
char: 'e',
hidden: false,
description: 'Name of the WebAssembly export to invoke.',
default: '_start',
env: 'SHOPIFY_FLAG_EXPORT',
}),
json: Flags.boolean({
char: 'j',
hidden: false,
description: 'Output the function run result as a JSON object.',
env: 'SHOPIFY_FLAG_JSON',
}),
}

public async run() {
const env = getEnvironmentVariables()
const logPollingEnabled = isTruthy(env[environmentVariableNames.enableAppLogPolling])

if (!logPollingEnabled) {
throw new Error(
'This command is not released yet. You can experiment with it by setting SHOPIFY_CLI_ENABLE_APP_LOG_POLLING=1 in your env.',
)
}

const {flags} = await this.parse(FunctionReplay)
if (flags['api-key']) {
await showApiKeyDeprecationWarning()
}
const apiKey = flags['client-id'] || flags['api-key']

await inFunctionContext({
path: flags.path,
userProvidedConfigName: flags.config,
callback: async (app, ourFunction) => {
await replay({
app,
extension: ourFunction,
apiKey,
stdout: flags.stdout,
path: flags.path,
json: flags.json,
export: flags.export,
})
},
})
}
}
2 changes: 1 addition & 1 deletion packages/app/src/cli/commands/app/function/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export default class FunctionRun extends Command {
export: Flags.string({
char: 'e',
hidden: false,
description: 'Name of the wasm export to invoke.',
description: 'Name of the WebAssembly export to invoke.',
default: '_start',
env: 'SHOPIFY_FLAG_EXPORT',
}),
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import DraftExtensionsPush from './commands/app/draft-extensions/push.js'
import EnvPull from './commands/app/env/pull.js'
import EnvShow from './commands/app/env/show.js'
import FunctionBuild from './commands/app/function/build.js'
import FunctionReplay from './commands/app/function/replay.js'
import FunctionRun from './commands/app/function/run.js'
import FetchSchema from './commands/app/function/schema.js'
import FunctionTypegen from './commands/app/function/typegen.js'
Expand Down Expand Up @@ -38,6 +39,7 @@ export const commands = {
'app:env:show': EnvShow,
'app:generate:schema': GenerateSchema,
'app:function:build': FunctionBuild,
'app:function:replay': FunctionReplay,
'app:function:run': FunctionRun,
'app:function:schema': FetchSchema,
'app:function:typegen': FunctionTypegen,
Expand Down
76 changes: 76 additions & 0 deletions packages/app/src/cli/prompts/function/replay.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import {selectFunctionRunPrompt} from './replay.js'
import {FunctionRunData} from '../../services/function/replay.js'
import {describe, expect, vi, test} from 'vitest'
import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui'

vi.mock('@shopify/cli-kit/node/ui')

const RUN1: FunctionRunData = {
shop_id: 69665030382,
api_client_id: 124042444801,
payload: {
input: '{}',
input_bytes: 136,
output: '{}',
output_bytes: 195,
function_id: '34236fa9-42f5-4bb6-adaf-956e12fff0b0',
logs: '',
fuel_consumed: 458206,
},
event_type: 'function_run',
cursor: '2024-05-31T15:29:47.291530Z',
status: 'success',
log_timestamp: '2024-05-31T15:29:46.741270Z',
identifier: 'abcdef',
}

const RUN2: FunctionRunData = {
shop_id: 69665030382,
api_client_id: 124042444801,
payload: {
input: '{}',
input_bytes: 136,
output: '{}',
output_bytes: 195,
function_id: '34236fa9-42f5-4bb6-adaf-956e12fff0b0',
logs: '',
fuel_consumed: 458206,
},
event_type: 'function_run',
cursor: '2024-05-31T15:29:47.291530Z',
status: 'success',
log_timestamp: '2024-05-31T15:29:46.741270Z',
identifier: 'abc123',
}

describe('selectFunctionRun', () => {
test('returns run if user selects one', async () => {
// Given
const runs = [RUN1, RUN2]
vi.mocked(renderAutocompletePrompt).mockResolvedValue(RUN2)

// When
const got = await selectFunctionRunPrompt(runs)

// Then
expect(got).toEqual(RUN2)
expect(renderAutocompletePrompt).toHaveBeenCalledWith({
message: 'Which function run would you like to replay locally?',
choices: [
{label: `${RUN1.log_timestamp} (${RUN1.status}) - ${RUN1.identifier}`, value: RUN1},
{label: `${RUN2.log_timestamp} (${RUN2.status}) - ${RUN2.identifier}`, value: RUN2},
],
})
})

test('returns undefined if no runs', async () => {
// Given
const runs: FunctionRunData[] = []

// When
const got = await selectFunctionRunPrompt(runs)

// Then
expect(got).toEqual(undefined)
})
})
20 changes: 20 additions & 0 deletions packages/app/src/cli/prompts/function/replay.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import {FunctionRunData} from '../../services/function/replay.js'
import {renderAutocompletePrompt} from '@shopify/cli-kit/node/ui'

export async function selectFunctionRunPrompt(functionRuns: FunctionRunData[]): Promise<FunctionRunData | undefined> {
if (functionRuns.length === 0) return undefined
const toAnswer = (functionRun: FunctionRunData) => {
return {
label: `${functionRun.log_timestamp} (${functionRun.status}) - ${functionRun.identifier}`,
value: functionRun,
}
}

const functionRunsList = functionRuns.map(toAnswer)

const selectedRun = await renderAutocompletePrompt({
message: 'Which function run would you like to replay locally?',
choices: functionRunsList,
})
return selectedRun
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,14 @@ describe('writeAppLogsToFile', () => {
const logData = expectedLogDataFromAppEvent(APP_LOG)

// determine the fileName and path
const fileName = `app_logs_${APP_LOG.log_timestamp}.json`
const fileName = '20240522_150641_827Z'
const path = joinPath(API_KEY, fileName)

// When
await writeAppLogsToFile({appLog: APP_LOG, apiKey: API_KEY, stdout})

// Then
expect(writeLog).toHaveBeenCalledWith(path, logData)
expect(writeLog).toHaveBeenCalledWith(expect.stringContaining(path), logData)
expect(stdout.write).toHaveBeenCalledWith(expect.stringContaining('Log: '))
})

Expand Down
21 changes: 18 additions & 3 deletions packages/app/src/cli/services/app-logs/write-app-logs.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {AppEventData} from './poll-app-logs.js'
import {joinPath} from '@shopify/cli-kit/node/path'
import {writeLog, getLogsDir} from '@shopify/cli-kit/node/logs'

import {randomUUID} from '@shopify/cli-kit/node/crypto'
import {Writable} from 'stream'

export const writeAppLogsToFile = async ({
Expand All @@ -13,9 +13,12 @@ export const writeAppLogsToFile = async ({
apiKey: string
stdout: Writable
}) => {
const fileName = `app_logs_${appLog.log_timestamp}.json`
const identifier = randomUUID().substring(0, 6)

const formattedTimestamp = formatTimestampToFilename(appLog.log_timestamp)
const fileName = `${formattedTimestamp}_${identifier}.json`
const path = joinPath(apiKey, fileName)
const fullOutputPath = joinPath(getLogsDir, path)
const fullOutputPath = joinPath(getLogsDir(), path)

try {
const toSaveData = {
Expand All @@ -32,3 +35,15 @@ export const writeAppLogsToFile = async ({
throw error
}
}

function formatTimestampToFilename(logTimestamp: string): string {
const date = new Date(logTimestamp)
const year = date.getUTCFullYear()
const month = (date.getUTCMonth() + 1).toString().padStart(2, '0')
const day = date.getUTCDate().toString().padStart(2, '0')
const hours = date.getUTCHours().toString().padStart(2, '0')
const minutes = date.getUTCMinutes().toString().padStart(2, '0')
const seconds = date.getUTCSeconds().toString().padStart(2, '0')
const milliseconds = date.getUTCMilliseconds().toString().padStart(3, '0')
return `${year}${month}${day}_${hours}${minutes}${seconds}_${milliseconds}Z`
}
Loading