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
18 changes: 9 additions & 9 deletions cli/src/channel/currentBundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { ChannelCurrentBundleOptions } from '../schemas/channel'
import { intro, log } from '@clack/prompts'
import { trackEvent, withSupabaseSource } from '../analytics/track'
import { check2FAComplianceForApp } from '../api/app'
import { CliUserError } from '../shared/cli-user-error'
import {
createSupabaseClient,
findSavedKey,
Expand Down Expand Up @@ -33,13 +34,13 @@ export async function currentBundleInternal(channel: string, appId: string, opti
if (!options.apikey) {
if (!silent)
log.error('Missing API key, you need to provide an API key to upload your bundle')
throw new Error('Missing API key')
throw new CliUserError('Missing API key')
}

if (!appId) {
if (!silent)
log.error('Missing argument, you need to provide a appId, or be in a capacitor project')
throw new Error('Missing appId')
throw new CliUserError('Missing appId')
}

const supabase = await createSupabaseClient(options.apikey, options.supaHost, options.supaAnon)
Expand All @@ -49,7 +50,7 @@ export async function currentBundleInternal(channel: string, appId: string, opti
if (!channel) {
if (!silent)
log.error('Please provide a channel to get the bundle from.')
throw new Error('Channel name missing')
throw new CliUserError('Channel name missing')
}

const { data: supabaseChannel, error } = await withSupabaseSource('channels.currentBundle', () => supabase
Expand All @@ -62,23 +63,22 @@ export async function currentBundleInternal(channel: string, appId: string, opti
if (error || !supabaseChannel?.length) {
if (!silent)
log.error(`Error retrieving channel ${channel} for app ${appId}. Perhaps the channel does not exist?`)
throw new Error(`Channel ${channel} not found for app ${appId}`)
throw new CliUserError('Channel not found for app', { appId, channel })
}

const { id: channelId, version } = supabaseChannel[0] as Channel
if (!(await hasCliPermission(supabase, options.apikey, 'channel.read', { appId, channelId }))) {
const msg = `Insufficient permissions for channel ${channel}. Required RBAC permission for this action: channel.read.`
if (!silent)
log.error(msg)
throw new Error(msg)
log.error(`Insufficient permissions for channel ${channel}. Required RBAC permission for this action: channel.read.`)
throw new CliUserError('Insufficient permissions for channel. Required RBAC permission for this action: channel.read.', { appId, channel })
}

void trackEvent({ channel: 'channel', event: 'Channel Current Bundle Viewed', icon: '📦', tags: { has_bundle: Boolean(version) } })

if (!version) {
if (!silent)
log.error(`Error retrieving channel ${channel} for app ${appId}. Perhaps the channel does not exist?`)
throw new Error(`Channel ${channel} does not have a bundle linked`)
throw new CliUserError('Channel does not have a bundle linked', { appId, channel })
}

const { data: bundleRows, error: bundleError } = await withSupabaseSource('channels.currentBundleName', () => supabase
Expand All @@ -91,7 +91,7 @@ export async function currentBundleInternal(channel: string, appId: string, opti
if (bundleError || !bundleName) {
if (!silent)
log.error(`Error retrieving current bundle for channel ${channel}.`)
throw new Error(`Channel ${channel} does not have a readable current bundle`)
throw new CliUserError('Channel does not have a readable current bundle', { appId, channel })
}

if (!silent) {
Expand Down
14 changes: 8 additions & 6 deletions cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1323,12 +1323,14 @@ void (async () => {
await Promise.all([capturePromise, flushAnalytics(), finishActiveCliReplay().catch(() => {})])
exit(exitCode)
}
const capturePromise = capturePosthogException({
error,
functionName: currentCommandPath,
kind: 'unhandled_error',
status: 1,
})
const capturePromise = shouldCapturePosthogException(error)
? capturePosthogException({
error,
functionName: currentCommandPath,
kind: 'unhandled_error',
status: 1,
})
: Promise.resolve(false)
// For non-Commander errors, show full error details
log.error(`Error: ${formatError(error)}`)
trackCommandFailed(currentCommandPath, { errorCategory: categorizeCliError(error), exitCode: 1 })
Expand Down
6 changes: 6 additions & 0 deletions cli/src/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Command } from 'commander'
import { homedir, platform, release } from 'node:os'
import { arch, cwd, env, version as nodeVersion } from 'node:process'
import pack from '../package.json'
import { CliUserError } from './shared/cli-user-error'

const POSTHOG_EXCEPTION_URL = 'https://eu.i.posthog.com/i/v0/e/'
const CAPGO_POSTHOG_PROJECT_TOKEN = 'phc_NXDyDajQaTQVwb25DEhIVZfxVUn4R0Y348Z7vWYHZUi'
Expand Down Expand Up @@ -177,6 +178,11 @@ function getCommanderCode(error: unknown) {
}

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)
return false
return !getCommanderCode(error)?.startsWith('commander.')
}

Expand Down
24 changes: 24 additions & 0 deletions cli/src/shared/cli-user-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* Marker for *expected* CLI failures that represent a legitimate user-facing
* state — missing input, a channel with no bundle linked, insufficient
* permissions — rather than a crash. The CLI still prints a clear message and
* exits non-zero, but `shouldCapturePosthogException` skips these so they never
* open an error tracking `$exception` issue. They are still counted via
* `trackCommandFailed` / `categorizeCliError`, so failure analytics stay intact.
*
* Keep any dynamic identifier (e.g. a channel name) OUT of the message and pass
* it via `context` instead: interpolating it into the message makes error
* tracking fingerprint a separate issue per value, which is exactly the noise
* this class exists to avoid.
*
* Precedent for domain-specific error markers already exists in the CLI with
* `MacOSSigningError` and `BuildRecordReadError`.
*/
export class CliUserError extends Error {
readonly context?: Record<string, unknown>
constructor(message: string, context?: Record<string, unknown>) {
super(message)
this.name = 'CliUserError'
this.context = context
}
}
12 changes: 12 additions & 0 deletions cli/test/test-posthog-exception.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
getCommandPath,
shouldCapturePosthogException,
} from '../src/posthog.ts'
import { CliUserError } from '../src/shared/cli-user-error.ts'

const originalFetch = globalThis.fetch
const originalEnv = {
Expand Down Expand Up @@ -132,6 +133,17 @@ try {
assert.equal(shouldCapturePosthogException({ code: 'ENOENT' }), true)
assert.equal(shouldCapturePosthogException(new Error('boom')), true)

// Expected user-facing CLI failures must never open an error tracking issue,
// regardless of the (dynamic) channel context attached to them.
assert.equal(shouldCapturePosthogException(new CliUserError('Channel does not have a bundle linked', { appId: 'com.example.app', channel: 'production' })), false)
assert.equal(shouldCapturePosthogException(new CliUserError('Missing API key')), false)
// Two failures on different channels must be treated identically (one issue,
// not one per channel), since the channel name lives in context, not the message.
assert.equal(
new CliUserError('Channel does not have a bundle linked', { channel: 'production' }).message,
new CliUserError('Channel does not have a bundle linked', { channel: 'canary' }).message,
)

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