Skip to content
Draft
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
63 changes: 63 additions & 0 deletions packages/app/src/cli/commands/app/app-logs/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import {queryAppLogs} from '../../../services/app-logs/query.js'
import BaseCommand from '@shopify/cli-kit/node/base-command'
import {globalFlags} from '@shopify/cli-kit/node/cli'
import {outputResult} from '@shopify/cli-kit/node/output'
import {Flags} from '@oclif/core'

export default class Query extends BaseCommand {
static hidden = true
static summary = 'Prototype only: query app log summaries through the local App Management API.'

static flags = {
...globalFlags,
'organization-id': Flags.string({
required: true,
env: 'SHOPIFY_FLAG_ORGANIZATION_ID',
description: 'Local Business Platform organization ID.',
}),
'client-id': Flags.string({required: true, env: 'SHOPIFY_FLAG_CLIENT_ID', description: 'App API key.'}),
minutes: Flags.integer({
default: 15,
min: 1,
env: 'SHOPIFY_FLAG_MINUTES',
description: 'Query the last N minutes.',
}),
limit: Flags.integer({
default: 10,
min: 1,
env: 'SHOPIFY_FLAG_LIMIT',
description: 'Maximum summary events to return.',
}),
offset: Flags.integer({
default: 0,
min: 0,
env: 'SHOPIFY_FLAG_OFFSET',
description: 'Number of matching rows to skip. Results are unordered; this is not a reliable export cursor.',
}),
type: Flags.string({
multiple: true,
env: 'SHOPIFY_FLAG_TYPE',
options: ['WEBHOOK_DELIVERY', 'GRAPHQL_REQUEST', 'REST_REQUEST', 'FUNCTION_RUN'],
description: 'Restrict results to these event types.',
}),
demo: Flags.boolean({
default: false,
env: 'SHOPIFY_FLAG_DEMO',
description: 'Use the loopback demo with seeded auth, not Identity login.',
}),
}

public async run(): Promise<void> {
const {flags} = await this.parse(Query)
const result = await queryAppLogs({
organizationId: flags['organization-id'],
clientId: flags['client-id'],
minutes: flags.minutes,
limit: flags.limit,
offset: flags.offset,
types: flags.type,
demo: flags.demo,
})
outputResult(JSON.stringify(result, null, 2))
}
}
2 changes: 2 additions & 0 deletions packages/app/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import DoctorSubmit from './commands/app/doctor/submit.js'
import Doctor from './commands/app/doctor.js'
import Logs from './commands/app/logs.js'
import Sources from './commands/app/app-logs/sources.js'
import QueryLogs from './commands/app/app-logs/query.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'
Expand Down Expand Up @@ -61,6 +62,7 @@ export const commands: {[key: string]: typeof AppLinkedCommand | typeof AppUnlin
'app:doctor': Doctor,
'app:logs': Logs,
'app:logs:sources': Sources,
'app:logs:query': QueryLogs,
'app:import-custom-data-definitions': ImportCustomDataDefinitions,
'app:import-extensions': ImportExtensions,
'app:info': AppInfo,
Expand Down
122 changes: 122 additions & 0 deletions packages/app/src/cli/services/app-logs/query.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import {queryAppLogs} from './query.js'
import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {fetch} from '@shopify/cli-kit/node/http'
import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session'
import {inTemporaryDirectory, writeFile} from '@shopify/cli-kit/node/fs'
import {joinPath} from '@shopify/cli-kit/node/path'
import {afterEach, beforeEach, expect, test, vi} from 'vitest'
import {Response} from 'node-fetch'

vi.mock('@shopify/cli-kit/node/context/fqdn')
vi.mock('@shopify/cli-kit/node/http')
vi.mock('@shopify/cli-kit/node/session')

const options = {organizationId: '1', clientId: 'test-app', minutes: 15, limit: 3, offset: 0, demo: false}

beforeEach(() => {
vi.stubEnv('SHOPIFY_APP_LOG_QUERY_PROTOTYPE', '1')
vi.stubEnv('SHOPIFY_SERVICE_ENV', 'local')
vi.mocked(appManagementFqdn).mockResolvedValue('app.shop.dev')
vi.mocked(ensureAuthenticatedAppManagementAndBusinessPlatform).mockResolvedValue({
appManagementToken: 'atkn_local-identity',
userId: 'local-user',
businessPlatformToken: 'unused',
})
})

afterEach(() => {
vi.unstubAllEnvs()
})

test('refuses production services before authenticating or sending a request', async () => {
vi.stubEnv('SHOPIFY_SERVICE_ENV', 'production')
await expect(queryAppLogs(options)).rejects.toThrow('Prototype only')
expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled()
expect(fetch).not.toHaveBeenCalled()
})

test('refuses an unexpected host before obtaining a token', async () => {
vi.mocked(appManagementFqdn).mockResolvedValue('app.shopify.com')
await expect(queryAppLogs(options)).rejects.toThrow('only call app.shop.dev')
expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled()
})

test('sends a bounded app query using the normal CLI authentication helper', async () => {
const result = {app_key: 'test-app', events: [{'payload.type': 'WEBHOOK_DELIVERY'}], exhaustive: false}
vi.mocked(fetch).mockResolvedValue(new Response(JSON.stringify(result), {status: 200}))
await expect(queryAppLogs({...options, types: ['WEBHOOK_DELIVERY']})).resolves.toEqual(result)
expect(fetch).toHaveBeenCalledWith(
'https://app.shop.dev/app_management/unstable/organizations/1/app_logs/query',
expect.objectContaining({
method: 'POST',
redirect: 'error',
headers: expect.objectContaining({authorization: 'Bearer atkn_local-identity'}),
body: expect.stringContaining('"types":["WEBHOOK_DELIVERY"]'),
}),
)
const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string)
expect(input.api_key).toBe('test-app')
expect(input.limit).toBe(3)
expect(input.offset).toBe(0)
expect(Date.parse(input.end_time) - Date.parse(input.start_time)).toBe(15 * 60 * 1000)
})

test('uses the temporary token only for the fixed loopback demo, without logging into Identity', async () => {
await inTemporaryDirectory(async (directory) => {
const path = joinPath(directory, 'token')
await writeFile(path, 'atkn_demo-only')
vi.stubEnv('APP_LOG_QUERY_DEMO_TOKEN_FILE', path)
vi.mocked(fetch).mockResolvedValue(new Response('{"events":[]}', {status: 200}))
await expect(queryAppLogs({...options, demo: true})).resolves.toEqual({events: []})
expect(fetch).toHaveBeenCalledWith(
'http://127.0.0.1:4387/app_management/unstable/organizations/1/app_logs/query',
expect.objectContaining({headers: expect.objectContaining({authorization: 'Bearer atkn_demo-only'})}),
)
expect(ensureAuthenticatedAppManagementAndBusinessPlatform).not.toHaveBeenCalled()
})
})

test('surfaces API errors instead of turning them into an empty result', async () => {
vi.mocked(fetch).mockResolvedValue(new Response('private upstream details', {status: 502}))
await expect(queryAppLogs(options)).rejects.toThrow('HTTP 502')
})

test('forwards a 10000 event limit and 1000000 offset independently', async () => {
vi.mocked(fetch).mockResolvedValue(new Response('{"events":[]}', {status: 200}))
await expect(queryAppLogs({...options, limit: 10_000, offset: 1_000_000})).resolves.toEqual({events: []})
const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string)
expect(input.limit).toBe(10_000)
expect(input.offset).toBe(1_000_000)
})

test('leaves upper policy limits to the server', async () => {
vi.mocked(fetch).mockResolvedValue(new Response('{"error":"Query limits exceeded"}', {status: 400}))
await expect(queryAppLogs({...options, minutes: 61, limit: 10_001, offset: 1_000_001})).rejects.toThrow('HTTP 400')
const input = JSON.parse(vi.mocked(fetch).mock.calls[0]![1]!.body as string)
expect(input.limit).toBe(10_001)
expect(input.offset).toBe(1_000_001)
})

test('reads valid responses larger than one MiB without a client byte cap', async () => {
const result = {events: [{'payload.target': 'x'.repeat(2 * 1024 * 1024)}]}
vi.mocked(fetch).mockImplementation(async (_url, init) => {
const responseOptions = {status: 200, size: init?.size}
return new Response(JSON.stringify(result), responseOptions)
})

await expect(queryAppLogs(options)).resolves.toEqual(result)
expect(vi.mocked(fetch).mock.calls[0]![1]?.size).toBeUndefined()
})

test('rejects a path-injection organization ID', async () => {
await expect(queryAppLogs({...options, organizationId: '../2'})).rejects.toThrow('numeric organization ID')
expect(fetch).not.toHaveBeenCalled()
})

test.each([{minutes: 0}, {minutes: 1.5}, {limit: 0}, {limit: 1.5}, {offset: -1}, {offset: 1.5}])(
'rejects malformed numeric options %j before making a request',
async (invalidOptions) => {
await expect(queryAppLogs({...options, ...invalidOptions})).rejects.toThrow('positive integers')
expect(fetch).not.toHaveBeenCalled()
},
)
74 changes: 74 additions & 0 deletions packages/app/src/cli/services/app-logs/query.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import {appManagementHeaders} from '@shopify/cli-kit/node/api/app-management'
import {appManagementFqdn} from '@shopify/cli-kit/node/context/fqdn'
import {AbortError} from '@shopify/cli-kit/node/error'
import {readFile} from '@shopify/cli-kit/node/fs'
import {fetch} from '@shopify/cli-kit/node/http'
import {ensureAuthenticatedAppManagementAndBusinessPlatform} from '@shopify/cli-kit/node/session'

interface QueryOptions {
organizationId: string
clientId: string
minutes: number
limit: number
offset: number
types?: string[]
demo: boolean
}

export async function queryAppLogs(options: QueryOptions): Promise<unknown> {
if (process.env.SHOPIFY_APP_LOG_QUERY_PROTOTYPE !== '1' || process.env.SHOPIFY_SERVICE_ENV !== 'local') {
throw new AbortError('Prototype only: set SHOPIFY_APP_LOG_QUERY_PROTOTYPE=1 and SHOPIFY_SERVICE_ENV=local.')
}
if (!/^\d+$/.test(options.organizationId) || !options.clientId) {
throw new AbortError('Provide a numeric organization ID and a nonempty app client ID.')
}
if (
!Number.isInteger(options.minutes) ||
options.minutes < 1 ||
!Number.isInteger(options.limit) ||
options.limit < 1 ||
!Number.isInteger(options.offset) ||
options.offset < 0
) {
throw new AbortError('Use positive integers for minutes and limit, and a nonnegative integer for offset.')
}

const {origin, token} = await queryConnection(options.demo)
const end = new Date()
const response = await fetch(
`${origin}/app_management/unstable/organizations/${options.organizationId}/app_logs/query`,
{
method: 'POST',
redirect: 'error',
signal: AbortSignal.timeout(15000),
headers: appManagementHeaders(token),
body: JSON.stringify({
api_key: options.clientId,
start_time: new Date(end.getTime() - options.minutes * 60 * 1000).toISOString(),
end_time: end.toISOString(),
limit: options.limit,
offset: options.offset,
...(options.types ? {types: options.types} : {}),
}),
},
)
if (!response.ok) {
throw new AbortError(`Local log query failed (HTTP ${response.status}). Check the local API server output.`)
}
return response.json()
}

async function queryConnection(demo: boolean): Promise<{origin: string; token: string}> {
if (demo) {
const path = process.env.APP_LOG_QUERY_DEMO_TOKEN_FILE
if (!path) throw new AbortError('Set APP_LOG_QUERY_DEMO_TOKEN_FILE to the file printed by the local demo server.')
const token = (await readFile(path)).trim()
if (!token.startsWith('atkn_') || token.length > 4096) throw new AbortError('Invalid demo token file.')
return {origin: 'http://127.0.0.1:4387', token}
}

const host = await appManagementFqdn()
if (host !== 'app.shop.dev') throw new AbortError('This prototype can only call app.shop.dev.')
const {appManagementToken} = await ensureAuthenticatedAppManagementAndBusinessPlatform()
return {origin: `https://${host}`, token: appManagementToken}
}
1 change: 1 addition & 0 deletions packages/cli/bin/bundle.js
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const hookEntryPoints = glob.sync('./src/hooks/*.ts', {
const manifest = JSON.parse(readFileSync(joinPath(process.cwd(), 'oclif.manifest.json'), 'utf8'))
const commandEntryPointOverrides = {
'app:logs:sources': 'cli/commands/app/app-logs/sources',
'app:logs:query': 'cli/commands/app/app-logs/query',
'demo:watcher': 'cli/commands/app/demo/watcher',
'kitchen-sink': 'cli/commands/kitchen-sink/index',
'doctor-release': 'cli/commands/doctor-release/doctor-release',
Expand Down
102 changes: 102 additions & 0 deletions packages/cli/oclif.manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -3313,6 +3313,108 @@
"strict": true,
"summary": "Stream detailed logs for your Shopify app."
},
"app:logs:query": {
"aliases": [
],
"args": {
},
"customPluginName": "@shopify/app",
"enableJsonFlag": false,
"flags": {
"client-id": {
"description": "App API key.",
"env": "SHOPIFY_FLAG_CLIENT_ID",
"hasDynamicHelp": false,
"multiple": false,
"name": "client-id",
"required": true,
"type": "option"
},
"demo": {
"allowNo": false,
"description": "Use the loopback demo with seeded auth, not Identity login.",
"env": "SHOPIFY_FLAG_DEMO",
"name": "demo",
"type": "boolean"
},
"limit": {
"default": 10,
"description": "Maximum summary events to return.",
"env": "SHOPIFY_FLAG_LIMIT",
"hasDynamicHelp": false,
"multiple": false,
"name": "limit",
"type": "option"
},
"minutes": {
"default": 15,
"description": "Query the last N minutes.",
"env": "SHOPIFY_FLAG_MINUTES",
"hasDynamicHelp": false,
"multiple": false,
"name": "minutes",
"type": "option"
},
"no-color": {
"allowNo": false,
"description": "Disable color output.",
"env": "SHOPIFY_FLAG_NO_COLOR",
"hidden": false,
"name": "no-color",
"type": "boolean"
},
"offset": {
"default": 0,
"description": "Number of matching rows to skip. Results are unordered; this is not a reliable export cursor.",
"env": "SHOPIFY_FLAG_OFFSET",
"hasDynamicHelp": false,
"multiple": false,
"name": "offset",
"type": "option"
},
"organization-id": {
"description": "Local Business Platform organization ID.",
"env": "SHOPIFY_FLAG_ORGANIZATION_ID",
"hasDynamicHelp": false,
"multiple": false,
"name": "organization-id",
"required": true,
"type": "option"
},
"type": {
"description": "Restrict results to these event types.",
"env": "SHOPIFY_FLAG_TYPE",
"hasDynamicHelp": false,
"multiple": true,
"name": "type",
"options": [
"WEBHOOK_DELIVERY",
"GRAPHQL_REQUEST",
"REST_REQUEST",
"FUNCTION_RUN"
],
"type": "option"
},
"verbose": {
"allowNo": false,
"description": "Increase the verbosity of the output. May include sensitive data.",
"env": "SHOPIFY_FLAG_VERBOSE",
"hidden": false,
"name": "verbose",
"type": "boolean"
}
},
"hasDynamicHelp": false,
"hidden": true,
"hiddenAliases": [
],
"id": "app:logs:query",
"pluginAlias": "@shopify/cli",
"pluginName": "@shopify/cli",
"pluginType": "core",
"strict": true,
"summary": "Prototype only: query app log summaries through the local App Management API."
},
"app:logs:sources": {
"aliases": [
],
Expand Down
Loading
Loading