577 lines
21 KiB
JavaScript
Executable File
577 lines
21 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
// Emits signals.json: Vercel CLI capability probe + project config + plan +
|
|
// usage + codebase stack + metric queries. Status → stderr, JSON → stdout.
|
|
// Degrades gracefully when capabilities are missing.
|
|
|
|
import {
|
|
checkCliVersion,
|
|
checkAuth,
|
|
resolveProjectId,
|
|
resolveCommandScope,
|
|
hasObservabilityPlus,
|
|
checkObservabilityPlusConfiguration,
|
|
getMetricsSchema,
|
|
getProjectConfig,
|
|
getAccountPlan,
|
|
getContract,
|
|
getUsage,
|
|
filterUsageByProject,
|
|
inferPlan,
|
|
queryMetric,
|
|
detectStack,
|
|
redactSensitiveText,
|
|
} from '../lib/vercel.mjs';
|
|
import { classifyFrameworkSupport } from '../lib/framework-support.mjs';
|
|
import { QUERIES, TIME_WINDOW, normalizerFor } from '../lib/queries.mjs';
|
|
|
|
const SCHEMA_VERSION = '1.2';
|
|
|
|
const log = (...args) => console.error('[collect-signals]', ...args);
|
|
|
|
function parseArgs(argv) {
|
|
let explicitProjectId = null;
|
|
let continueWithoutObservability = process.env.VERCEL_OPTIMIZE_CONTINUE_WITHOUT_OBSERVABILITY === '1';
|
|
let continueUnsupportedFramework = process.env.VERCEL_OPTIMIZE_CONTINUE_UNSUPPORTED_FRAMEWORK === '1';
|
|
|
|
for (const arg of argv) {
|
|
if (arg === '--continue-without-observability') {
|
|
continueWithoutObservability = true;
|
|
continue;
|
|
}
|
|
if (arg === '--continue-unsupported-framework') {
|
|
continueUnsupportedFramework = true;
|
|
continue;
|
|
}
|
|
if (arg.startsWith('--')) {
|
|
throw new Error(`UNKNOWN_ARG: ${arg}`);
|
|
}
|
|
if (!explicitProjectId) {
|
|
explicitProjectId = arg;
|
|
continue;
|
|
}
|
|
throw new Error(`UNKNOWN_ARG: ${arg}`);
|
|
}
|
|
|
|
return { explicitProjectId, continueWithoutObservability, continueUnsupportedFramework };
|
|
}
|
|
|
|
async function main() {
|
|
const { explicitProjectId, continueWithoutObservability, continueUnsupportedFramework } = parseArgs(process.argv.slice(2));
|
|
|
|
log('checking Vercel CLI version…');
|
|
const cli = await checkCliVersion();
|
|
log(`vercel CLI v${cli.join('.')} OK`);
|
|
|
|
log('checking auth…');
|
|
await checkAuth();
|
|
log('auth OK');
|
|
|
|
log('resolving project id…');
|
|
const project = await resolveProjectId(explicitProjectId);
|
|
if (!project) {
|
|
throw new Error(
|
|
'NO_PROJECT_ID: pass one as argv, set VERCEL_PROJECT_ID, or run `vercel link` in this directory.'
|
|
);
|
|
}
|
|
log(`project link resolved (source=${project.source}; teamScope=${project.orgId ? 'yes' : 'no'})`);
|
|
|
|
if (!project.orgId) {
|
|
throw new Error('PROJECT_SCOPE_UNRESOLVED: the project was resolved without an owner account. Ask the user which Vercel team or personal scope owns the project, then rerun from a linked app directory or set VERCEL_PROJECT_ID with VERCEL_ORG_ID for that scope.');
|
|
}
|
|
|
|
log('checking framework support…');
|
|
const stack = await detectStack();
|
|
const frameworkSupport = classifyFrameworkSupport(stack);
|
|
log(`framework=${stack.framework}@${stack.frameworkVersion ?? '?'} support=${frameworkSupport.status}`);
|
|
|
|
if (!frameworkSupport.ok && !continueUnsupportedFramework) {
|
|
writeOutput({
|
|
schemaVersion: SCHEMA_VERSION,
|
|
collectedAt: new Date().toISOString(),
|
|
timeWindow: TIME_WINDOW,
|
|
projectId: project.projectId,
|
|
orgId: project.orgId,
|
|
projectIdSource: project.source,
|
|
commandScope: null,
|
|
frameworkSupport,
|
|
frameworkSupportBlocker: frameworkSupport.blocker,
|
|
frameworkSupportDetail: frameworkSupport.detail,
|
|
observabilityPlus: null,
|
|
observabilityPlusPreflight: null,
|
|
observabilityPlusUsable: null,
|
|
observabilityPlusBlocker: null,
|
|
observabilityPlusBlockerDetail: null,
|
|
plan: {
|
|
plan: 'uncertain',
|
|
reason: 'not collected before unsupported-framework confirmation',
|
|
},
|
|
project: null,
|
|
contract: null,
|
|
usage: null,
|
|
usageScope: null,
|
|
usageTeamTotal: null,
|
|
usageError: 'NOT_COLLECTED_UNSUPPORTED_FRAMEWORK',
|
|
stack,
|
|
metrics: {},
|
|
metricsSchema: null,
|
|
}, { usable: true, blocker: null, detail: 'Observability Plus was not checked.' }, frameworkSupport);
|
|
return;
|
|
}
|
|
|
|
if (!frameworkSupport.ok && continueUnsupportedFramework) {
|
|
log('continuing after unsupported framework blocker because --continue-unsupported-framework was set');
|
|
}
|
|
|
|
log('resolving Vercel CLI command scope…');
|
|
const commandScope = await resolveCommandScope(project);
|
|
if (!commandScope.ok) {
|
|
throw new Error(`SCOPE_UNRESOLVED: ${commandScope.detail} Run \`vercel switch <team>\` or re-link with \`vercel link --yes --project <project-name-or-id> --team <team-slug>\`.`);
|
|
}
|
|
const scope = commandScope.cliScope || undefined;
|
|
log(`command scope resolved (source=${commandScope.source}; scoped=${scope ? 'yes' : 'no'})`);
|
|
|
|
log('validating linked project belongs to the resolved scope…');
|
|
const projectCfg = await getProjectConfig(project.projectId, project.orgId);
|
|
const projectScope = validateProjectScope(projectCfg, project);
|
|
if (!projectScope.ok) {
|
|
throw new Error(`PROJECT_SCOPE_MISMATCH: ${projectScope.detail} Ask the user to confirm the exact Vercel project and team/personal scope, then rerun after \`vercel link --yes --project <project-name-or-id> --team <team-slug>\` or after setting both VERCEL_PROJECT_ID and VERCEL_ORG_ID for the intended scope.`);
|
|
}
|
|
log(`project scope verified (source=${projectScope.source})`);
|
|
|
|
log('checking Observability Plus configuration…');
|
|
const observabilityPlusConfig = await checkObservabilityPlusConfiguration({
|
|
orgId: project.orgId,
|
|
projectId: project.projectId,
|
|
});
|
|
log(`observabilityPlusPreflight=${observabilityPlusConfig.access === true ? 'enabled' : observabilityPlusConfig.blocker ?? 'unknown'} (${observabilityPlusConfig.source})`);
|
|
|
|
let oplus = observabilityPlusConfig.access === true;
|
|
if (observabilityPlusConfig.access == null) {
|
|
log('Observability Plus configuration preflight inconclusive; falling back to metrics schema probe…');
|
|
oplus = await hasObservabilityPlus(scope);
|
|
}
|
|
log(`observabilityPlus=${oplus}`);
|
|
|
|
const schema = oplus ? await getMetricsSchema(scope) : null;
|
|
if (oplus && schema) {
|
|
const count = Array.isArray(schema) ? schema.length : (schema.metrics?.length ?? 0);
|
|
log(`metric catalog: ${count} metrics available`);
|
|
}
|
|
|
|
// Check one cheap metric before pulling slower project context. If this fails,
|
|
// the orchestrator can ask the user immediately instead of waiting on billing.
|
|
let metrics = {};
|
|
let metricsCanaryOk = false;
|
|
if (oplus) {
|
|
log(`checking Observability Plus metrics access (window=${TIME_WINDOW})…`);
|
|
const t0 = Date.now();
|
|
const canary = await queryMetric('vercel.request.count', {
|
|
aggregation: 'sum',
|
|
since: TIME_WINDOW,
|
|
limit: 1,
|
|
scope,
|
|
});
|
|
metricsCanaryOk = !!canary?.ok;
|
|
if (!metricsCanaryOk) {
|
|
metrics = {
|
|
observabilityPlusCanary: {
|
|
...canary,
|
|
metricId: 'vercel.request.count',
|
|
aggregation: 'sum',
|
|
},
|
|
};
|
|
log(`metrics access check failed: ${canary?.code ?? 'unknown'} — skipping full metrics fan-out`);
|
|
} else {
|
|
log(`metrics access check passed in ${Date.now() - t0}ms`);
|
|
}
|
|
} else {
|
|
log('skipping metric queries (Observability Plus preflight did not confirm access)');
|
|
}
|
|
|
|
let oplusDiag = observabilityPlusConfig.access === false
|
|
? {
|
|
usable: false,
|
|
blocker: observabilityPlusConfig.blocker,
|
|
detail: observabilityPlusConfig.detail,
|
|
}
|
|
: (metricsCanaryOk
|
|
? { usable: true, blocker: null, detail: 'Observability Plus metrics access check passed.' }
|
|
: diagnoseObservabilityPlus(metrics, oplus));
|
|
|
|
if (!oplusDiag.usable && !continueWithoutObservability) {
|
|
writeOutput({
|
|
schemaVersion: SCHEMA_VERSION,
|
|
collectedAt: new Date().toISOString(),
|
|
timeWindow: TIME_WINDOW,
|
|
projectId: project.projectId,
|
|
orgId: project.orgId,
|
|
projectIdSource: project.source,
|
|
commandScope,
|
|
observabilityPlus: oplus,
|
|
observabilityPlusPreflight: observabilityPlusConfig,
|
|
observabilityPlusUsable: oplusDiag.usable,
|
|
observabilityPlusBlocker: oplusDiag.blocker,
|
|
observabilityPlusBlockerDetail: oplusDiag.detail,
|
|
frameworkSupport,
|
|
frameworkSupportBlocker: frameworkSupport.blocker,
|
|
frameworkSupportDetail: frameworkSupport.detail,
|
|
plan: {
|
|
plan: 'uncertain',
|
|
reason: 'not collected before Observability Plus blocker confirmation',
|
|
},
|
|
project: projectCfg,
|
|
contract: null,
|
|
usage: null,
|
|
usageScope: null,
|
|
usageTeamTotal: null,
|
|
usageError: 'NOT_COLLECTED_OBSERVABILITY_BLOCKED',
|
|
stack: null,
|
|
metrics,
|
|
metricsSchema: schema,
|
|
}, oplusDiag);
|
|
return;
|
|
}
|
|
|
|
if (!oplusDiag.usable && continueWithoutObservability) {
|
|
log('continuing after Observability Plus blocker because --continue-without-observability was set');
|
|
}
|
|
|
|
log('pulling account plan + contract + usage in parallel…');
|
|
const [accountPlan, contract, usageResult] = await Promise.all([
|
|
getAccountPlan(project.orgId || scope),
|
|
getContract(scope),
|
|
getUsage({ days: 14, scope }),
|
|
]);
|
|
|
|
let usage = null;
|
|
let usageContextMismatch = false;
|
|
let usageTotalCost = null;
|
|
let usageScope = 'team';
|
|
let usageTeamTotal = null;
|
|
if (usageResult?.ok) {
|
|
usage = usageResult.data;
|
|
const contractContext = contract?.context;
|
|
if (usage?.context && contractContext && usage.context !== contractContext) {
|
|
usageContextMismatch = true;
|
|
log(`usage: WARNING context mismatch — returned context=${usage.context} but project team=${contractContext}; treating usage as unavailable for this project`);
|
|
usage = null;
|
|
} else {
|
|
// Capture team total pre-filter so the report can label "this project vs team-wide" honestly.
|
|
usageTeamTotal = sumUsageCosts(usage);
|
|
const filterResult = filterUsageByProject(usage, project.projectId, projectCfg?.name);
|
|
if (filterResult.matched) {
|
|
usage = filterResult.filtered;
|
|
usageScope = 'project';
|
|
usageTotalCost = sumUsageCosts(usage);
|
|
log(`usage: filtered to project — ~$${usageTotalCost.toFixed(2)} (team-wide ~$${usageTeamTotal.toFixed(2)}; unattributed ~$${filterResult.unattributedTotal.toFixed(2)})`);
|
|
} else {
|
|
usageTotalCost = usageTeamTotal;
|
|
log(`usage: ~$${usageTotalCost.toFixed(2)} billed across services (team-wide — no per-project usage rows matched the linked project; report will label this team-wide)`);
|
|
}
|
|
}
|
|
} else {
|
|
log(`usage: unavailable (${usageResult?.code ?? 'unknown'}) — degrading to scanner+metrics-only mode`);
|
|
}
|
|
|
|
const planInfo = inferPlan(contract, { accountPlan, usageTotalCost });
|
|
log(`plan=${planInfo.plan} (${planInfo.reason})`);
|
|
|
|
if (projectCfg?.error) {
|
|
log(`project config: failed (${projectCfg.error}) — gates that need it will skip`);
|
|
}
|
|
|
|
log(`stack: ${stack.framework}@${stack.frameworkVersion ?? '?'} ${stack.hasAppRouter ? 'app-router' : ''}${stack.hasPagesRouter ? ' pages-router' : ''}${stack.orm !== 'none' ? ` orm=${stack.orm}` : ''}`);
|
|
|
|
// Each query is wrapped; one failure degrades only that metric.
|
|
if (oplus && metricsCanaryOk) {
|
|
log(`querying observability metrics (${QUERIES.length} metrics in parallel)…`);
|
|
const t0 = Date.now();
|
|
metrics = await collectMetrics(scope);
|
|
const wallMs = Date.now() - t0;
|
|
const counts = Object.fromEntries(
|
|
Object.entries(metrics).map(([k, v]) => {
|
|
if (!v) return [k, 'null'];
|
|
if (!v.ok) return [k, `err:${v.code}`];
|
|
const rows = Array.isArray(v.rows) ? v.rows.length : 0;
|
|
return [k, `${rows} rows`];
|
|
})
|
|
);
|
|
log(`metrics collected in ${wallMs}ms: ${JSON.stringify(counts)}`);
|
|
}
|
|
|
|
// The `vercel metrics schema` probe alone is NOT a reliable usability signal:
|
|
// it can return OK while per-route queries fail with payment_required (metrics
|
|
// unavailable for the team) or FORBIDDEN (auth-scope mismatch). Diagnose AFTER
|
|
// running queries by counting failure codes so the orchestrator can PAUSE and
|
|
// surface the choice before falling back to scanner-only mode.
|
|
oplusDiag = observabilityPlusConfig.access === false
|
|
? {
|
|
usable: false,
|
|
blocker: observabilityPlusConfig.blocker,
|
|
detail: observabilityPlusConfig.detail,
|
|
}
|
|
: diagnoseObservabilityPlus(metrics, oplus);
|
|
|
|
|
|
const output = {
|
|
schemaVersion: SCHEMA_VERSION,
|
|
collectedAt: new Date().toISOString(),
|
|
timeWindow: TIME_WINDOW,
|
|
projectId: project.projectId,
|
|
orgId: project.orgId,
|
|
projectIdSource: project.source,
|
|
commandScope,
|
|
observabilityPlus: oplus,
|
|
observabilityPlusPreflight: observabilityPlusConfig,
|
|
observabilityPlusUsable: oplusDiag.usable,
|
|
observabilityPlusBlocker: oplusDiag.blocker,
|
|
observabilityPlusBlockerDetail: oplusDiag.detail,
|
|
frameworkSupport,
|
|
frameworkSupportBlocker: frameworkSupport.blocker,
|
|
frameworkSupportDetail: frameworkSupport.detail,
|
|
plan: planInfo,
|
|
project: projectCfg,
|
|
contract,
|
|
usage,
|
|
usageScope,
|
|
usageTeamTotal,
|
|
usageError: usageResult?.ok
|
|
? (usageContextMismatch ? 'USAGE_CONTEXT_MISMATCH' : null)
|
|
: (usageResult?.code ?? 'UNKNOWN'),
|
|
stack,
|
|
metrics,
|
|
metricsSchema: schema,
|
|
};
|
|
|
|
writeOutput(output, oplusDiag);
|
|
}
|
|
|
|
function writeOutput(output, oplusDiag, frameworkSupport = output.frameworkSupport) {
|
|
if (frameworkSupport?.blocker) {
|
|
log(`⚠ Framework is not supported for metric-backed route-to-file optimization: ${frameworkSupport.detail}`);
|
|
log(' The orchestrator should PAUSE and ask whether to continue with a limited platform/scanner audit.');
|
|
}
|
|
if (!oplusDiag.usable) {
|
|
log(`⚠ Observability Plus is NOT usable on this project: blocker=${oplusDiag.blocker} (${oplusDiag.detail})`);
|
|
log(' The orchestrator should PAUSE and follow the blocker-specific remediation before proceeding.');
|
|
}
|
|
|
|
process.stdout.write(JSON.stringify(output, null, 2) + '\n');
|
|
log('done');
|
|
}
|
|
|
|
function validateProjectScope(projectCfg, project) {
|
|
if (!projectCfg || projectCfg.error) {
|
|
return {
|
|
ok: false,
|
|
source: 'project-api',
|
|
detail: `The resolved account could not read the resolved project (project API error=${projectCfg?.error ?? 'unknown'}).`,
|
|
};
|
|
}
|
|
|
|
if (projectCfg.id && String(projectCfg.id) !== String(project.projectId)) {
|
|
return {
|
|
ok: false,
|
|
source: 'project-api',
|
|
detail: 'The project API returned a different project than the collector resolved from the link or environment.',
|
|
};
|
|
}
|
|
|
|
const ownerId = firstString(
|
|
projectCfg.accountId,
|
|
projectCfg.orgId,
|
|
projectCfg.ownerId,
|
|
projectCfg.teamId,
|
|
projectCfg.team?.id,
|
|
projectCfg.account?.id,
|
|
projectCfg.owner?.id,
|
|
);
|
|
if (ownerId && project.orgId && String(ownerId) !== String(project.orgId)) {
|
|
return {
|
|
ok: false,
|
|
source: 'project-api',
|
|
detail: 'The project API returned an owner account that differs from the collector-resolved account.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
source: ownerId ? 'project-api-owner' : 'project-api-readable',
|
|
};
|
|
}
|
|
|
|
function firstString(...values) {
|
|
return values.find((value) => typeof value === 'string' && value.trim() !== '') ?? null;
|
|
}
|
|
|
|
async function collectMetrics(scope) {
|
|
const results = await Promise.all(
|
|
QUERIES.map(async (entry) => {
|
|
const r = await queryMetric(entry.metricId, {
|
|
aggregation: entry.aggregation,
|
|
groupBy: entry.groupBy,
|
|
filter: entry.filter,
|
|
since: TIME_WINDOW,
|
|
limit: entry.limit,
|
|
scope,
|
|
});
|
|
return [entry, r];
|
|
})
|
|
);
|
|
|
|
const out = {};
|
|
for (const [entry, result] of results) {
|
|
out[entry.id] = enrichEntry(entry, result);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function enrichEntry(entry, result) {
|
|
if (!result?.ok) {
|
|
return {
|
|
...result,
|
|
metricId: entry.metricId,
|
|
aggregation: entry.aggregation,
|
|
groupBy: entry.groupBy,
|
|
};
|
|
}
|
|
const normalize = normalizerFor(entry);
|
|
const { rows } = normalize(result.data);
|
|
return {
|
|
...result,
|
|
rows,
|
|
metricId: entry.metricId,
|
|
aggregation: entry.aggregation,
|
|
groupBy: entry.groupBy,
|
|
};
|
|
}
|
|
|
|
// `vercel usage --format json` shape is documented but not stable across CLI
|
|
// versions; try several roots, return null if none match.
|
|
function sumUsageCosts(usage) {
|
|
if (!usage) return null;
|
|
if (typeof usage.totalCost === 'number') return usage.totalCost;
|
|
if (typeof usage.totals?.billedCost === 'number') return usage.totals.billedCost;
|
|
if (Array.isArray(usage.services)) {
|
|
return usage.services.reduce((s, x) => s + (x.billedCost ?? x.cost ?? 0), 0);
|
|
}
|
|
if (Array.isArray(usage.breakdown?.data)) {
|
|
return usage.breakdown.data.reduce((s, d) => {
|
|
if (Array.isArray(d.services)) {
|
|
return s + d.services.reduce((ss, x) => ss + (x.billedCost ?? x.cost ?? 0), 0);
|
|
}
|
|
return s + (d.billedCost ?? d.cost ?? 0);
|
|
}, 0);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Returns { usable, blocker, detail }. `blocker` enum:
|
|
// null | 'no_oplus_probe' | 'project_disabled' | 'payment_required' |
|
|
// 'forbidden' | 'daily_quota_exceeded' | 'project_not_found' |
|
|
// 'not_linked' | 'all_failed_other' | 'no_traffic'
|
|
export function diagnoseObservabilityPlus(metrics, oplusProbe) {
|
|
if (!oplusProbe) {
|
|
return {
|
|
usable: false,
|
|
blocker: 'no_oplus_probe',
|
|
detail: 'vercel metrics schema returned non-OK; the team does not have Observability Plus enabled.',
|
|
};
|
|
}
|
|
|
|
const entries = Object.values(metrics);
|
|
if (entries.length === 0) {
|
|
return { usable: false, blocker: 'no_oplus_probe', detail: 'No metrics were attempted.' };
|
|
}
|
|
|
|
const failures = entries.filter((m) => m && m.ok === false);
|
|
const successes = entries.filter((m) => m && m.ok !== false);
|
|
|
|
if (successes.length === 0) {
|
|
const codeCounts = new Map();
|
|
for (const f of failures) {
|
|
const code = String(f.code ?? 'unknown').toLowerCase();
|
|
codeCounts.set(code, (codeCounts.get(code) ?? 0) + 1);
|
|
}
|
|
const top = [...codeCounts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
const topCode = top?.[0] ?? 'unknown';
|
|
if (/daily_quota_exceeded/.test(topCode)) {
|
|
return {
|
|
usable: false,
|
|
blocker: 'daily_quota_exceeded',
|
|
detail: `${top[1]}/${entries.length} metric queries hit the daily Observability query limit. Retry after the next UTC midnight reset.`,
|
|
};
|
|
}
|
|
if (/payment_required/.test(topCode)) {
|
|
const text = failures
|
|
.map((f) => `${f.message ?? ''}\n${f.stderr ?? ''}`)
|
|
.join('\n')
|
|
.toLowerCase();
|
|
if (
|
|
/subscription to observability plus[\s\S]{0,160}required/.test(text) ||
|
|
/observability plus[\s\S]{0,160}not enabled/.test(text)
|
|
) {
|
|
return {
|
|
usable: false,
|
|
blocker: 'no_oplus_probe',
|
|
detail: `${top[1]}/${entries.length} metric queries need route-level Observability Plus data. Enable Observability Plus, then re-run the metric-backed audit.`,
|
|
};
|
|
}
|
|
return {
|
|
usable: false,
|
|
blocker: 'payment_required',
|
|
detail: `${top[1]}/${entries.length} metric queries returned payment_required. Route-level metrics were recognized for this team, but these queries are not usable. Check the team's Observability Plus subscription or event quota.`,
|
|
};
|
|
}
|
|
if (/forbidden|not_authorized|403/.test(topCode)) {
|
|
return {
|
|
usable: false,
|
|
blocker: 'forbidden',
|
|
detail: `${top[1]}/${entries.length} metric queries returned FORBIDDEN. Auth-scope mismatch — likely logged in to the wrong team (run \`vercel switch\`).`,
|
|
};
|
|
}
|
|
if (/project_not_found/.test(topCode)) {
|
|
return {
|
|
usable: false,
|
|
blocker: 'project_not_found',
|
|
detail: `Project ID not visible to the auth'd team. Run \`vercel switch\` or verify the project ID.`,
|
|
};
|
|
}
|
|
if (/not_linked/.test(topCode)) {
|
|
return {
|
|
usable: false,
|
|
blocker: 'not_linked',
|
|
detail: `${top[1]}/${entries.length} metric queries returned NOT_LINKED. Link the app directory first: \`vercel link --yes --project <project-name-or-id> --cwd <project-dir>\`; add \`--team <team-id-or-slug>\` when the team is known.`,
|
|
};
|
|
}
|
|
return {
|
|
usable: false,
|
|
blocker: 'all_failed_other',
|
|
detail: `Every metric query failed; top error code was \`${topCode}\` (${top?.[1]}/${entries.length}).`,
|
|
};
|
|
}
|
|
|
|
// Some queries succeeded; zero rows across the board = "no traffic in window",
|
|
// NOT an Observability Plus billing issue.
|
|
const totalRows = successes.reduce((s, m) => s + (Array.isArray(m.rows) ? m.rows.length : 0), 0);
|
|
if (totalRows === 0) {
|
|
return {
|
|
usable: true,
|
|
blocker: 'no_traffic',
|
|
detail: 'Observability Plus queries succeeded but every metric returned 0 rows. Either the project has no traffic in the 14-day window, or Observability Plus retention is limited (free tier = 1 day on Pro).',
|
|
};
|
|
}
|
|
|
|
return { usable: true, blocker: null, detail: 'Observability Plus is usable; queries returned data.' };
|
|
}
|
|
|
|
// Run main() only as a CLI; the test suite imports diagnoseObservabilityPlus directly.
|
|
import { fileURLToPath } from 'node:url';
|
|
import { realpathSync } from 'node:fs';
|
|
if (process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) {
|
|
main().catch((err) => {
|
|
console.error('[collect-signals] FAILED:', redactSensitiveText(err.message));
|
|
process.exit(1);
|
|
});
|
|
}
|