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
8 changes: 7 additions & 1 deletion cli/src/bundle/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1341,7 +1341,13 @@ async function uploadBundleInternalWithReporter(preAppid: string, options: Optio
if (options.verbose)
log.info(`[Verbose] Target channel${channels.length > 1 ? 's' : ''}: ${channelLabel}`)

// Now if it does exist we will fetch the org id
// Verify the app exists and this key may upload BEFORE the org lookup, so a
// missing app or bad key yields an actionable message (e.g. run `app add`)
// instead of the opaque "Cannot get organization id" thrown below.
if (options.verbose)
log.info(`[Verbose] Checking app existence and upload permission...`)
await checkAppExistsAndHasPermissionOrgErr(supabase, apikey, appid, 'app.upload_bundle', silent, true)

const orgId = await getOrganizationId(supabase, appid)
if (options.verbose)
log.info(`[Verbose] Organization ID: ${orgId}`)
Expand Down
56 changes: 51 additions & 5 deletions cli/src/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,11 +177,56 @@ function getCommanderCode(error: unknown) {
return typeof code === 'string' ? code : undefined
}

// Lowercased markers for expected user-configuration failures (bad/missing API
// key, app not created yet). These are surfaced with the backend error codes or
// the CLI's own wording — matched case-insensitively against the error message.
const EXPECTED_USER_ERROR_MARKERS = [
'invalid_apikey',
'invalid apikey',
'no_key_provided',
'no key provided',
'invalid api key or insufficient permissions',
'does not exist, run first',
]

function getErrorStatus(error: unknown): number | undefined {
if (!error || typeof error !== 'object')
return undefined
const candidate = error as { status?: unknown, statusCode?: unknown, context?: { status?: unknown } }
if (typeof candidate.status === 'number')
return candidate.status
if (typeof candidate.statusCode === 'number')
return candidate.statusCode
// supabase-js FunctionsHttpError keeps the upstream Response on `.context`.
const contextStatus = candidate.context && typeof candidate.context === 'object'
? (candidate.context as { status?: unknown }).status
: undefined
return typeof contextStatus === 'number' ? contextStatus : undefined
}

/**
* Expected user-configuration failures: a bad or missing API key (401
* `invalid_apikey` / `no_key_provided`) or an app that has not been created
* yet. These are real user errors, not CLI bugs — they are still counted via
* `trackCommandFailed`, but must NOT be sent to error tracking as exceptions.
*/
export function isExpectedUserError(error: unknown) {
const message = (error instanceof Error
? error.message
: typeof error === 'string' ? error : '').toLowerCase()

if (EXPECTED_USER_ERROR_MARKERS.some(marker => message.includes(marker)))
return true

// A 401 from any Capgo endpoint is an auth/config problem on the caller side.
return getErrorStatus(error) === 401
}

export function shouldCapturePosthogException(error: unknown) {
// Expected user-facing failures (missing input, a channel with no bundle,
// insufficient permissions, …) are legitimate states, not crashes — never
// open an error tracking issue for them.
if (error instanceof CliUserError)
// Expected user-facing failures (CliUserError, bad/missing key, app not
// created, …) are legitimate states, not crashes — never open an error
// tracking issue for them.
if (error instanceof CliUserError || isExpectedUserError(error))
return false
return !getCommanderCode(error)?.startsWith('commander.')
}
Expand Down Expand Up @@ -221,7 +266,8 @@ export async function capturePosthogException(payload: CapturePosthogExceptionPa
const topFrame = frames[0]
// Deliberately exclude the CLI version from the fingerprint so the same bug
// stays a single error-tracking issue across releases instead of minting a
// brand-new issue on every version bump.
// brand-new issue on every version bump. Version is still reported via
// `cli_version` below.
const fingerprint = [
payload.functionName,
payload.kind,
Expand Down
5 changes: 3 additions & 2 deletions cli/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2062,8 +2062,9 @@ export async function getOrganizationId(supabase: SupabaseClient<Database>, appI
.single()

if (!data || error) {
log.error(`Cannot get organization id for app id ${appId}`)
formatError(error)
// Surface the underlying PostgREST cause instead of discarding it — a bare
// "Cannot get organization id" leaves both users and triage with no signal.
log.error(`Cannot get organization id for app id ${appId}: ${formatError(error)}`)
throw new Error(`Cannot get organization id for app id ${appId}`)
}
return data.owner_org
Expand Down
18 changes: 17 additions & 1 deletion cli/test/test-posthog-exception.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Command } from 'commander'
import {
capturePosthogException,
getCommandPath,
isExpectedUserError,
shouldCapturePosthogException,
} from '../src/posthog.ts'
import { CliUserError } from '../src/shared/cli-user-error.ts'
Expand Down Expand Up @@ -67,9 +68,10 @@ try {
assert.equal(body.properties.status, 1)
assert.match(body.properties.distinct_id, /^cli:[^:]+:bundle upload$/)
// Fingerprint must NOT include the CLI version, so the same bug stays one
// error-tracking issue across releases.
// error-tracking issue across releases (version still reported via cli_version).
assert.equal(body.properties.$exception_fingerprint, 'bundle upload:unhandled_error:Error:runUpload:<cwd>/src/index.ts:1')
assert.doesNotMatch(body.properties.$exception_fingerprint, /cli:/)
assert.equal(body.properties.cli_version, body.properties.distinct_id.split(':')[1])
assert.equal(body.properties.$exception_list[0].type, 'Error')
assert.equal(body.properties.$exception_list[0].value, 'boom')
assert.equal(body.properties.$exception_list[0].mechanism.handled, true)
Expand Down Expand Up @@ -147,6 +149,20 @@ try {
new CliUserError('Channel does not have a bundle linked', { channel: 'canary' }).message,
)

// Expected user errors must be skipped by exception capture (they are still
// counted via trackCommandFailed at the call site).
assert.equal(isExpectedUserError(new Error('Invalid API key or insufficient permissions.')), true)
assert.equal(isExpectedUserError(new Error('invalid_apikey')), true)
assert.equal(isExpectedUserError(new Error('no_key_provided')), true)
assert.equal(isExpectedUserError(new Error('App com.example does not exist, run first `npx @capgo/cli app add com.example` to create it')), true)
assert.equal(isExpectedUserError({ context: { status: 401 } }), true)
assert.equal(isExpectedUserError({ status: 401 }), true)
assert.equal(isExpectedUserError(new Error('Cannot get organization id for app id com.example')), false)
assert.equal(isExpectedUserError(new Error('boom')), false)
assert.equal(shouldCapturePosthogException(new Error('invalid_apikey')), false)
assert.equal(shouldCapturePosthogException({ context: { status: 401 } }), false)
assert.equal(shouldCapturePosthogException(new Error('Cannot get organization id for app id com.example')), true)

console.log('CLI PostHog exception capture tests passed')
}
finally {
Expand Down
Loading