From 1aace82d437a5ce8104d1fd5c2d5241b1a4eb3c4 Mon Sep 17 00:00:00 2001 From: Vi Date: Thu, 10 Sep 2026 13:26:56 -0700 Subject: [PATCH] feat(integration): connect Logfire with a project key and an organization key Logfire issues the query scope on a key tied to one project and the notification-channel scope on an organization key, so the API connect body for Logfire takes both (coreplanelabs/nominal#2507). The CLI had no Logfire flow at all while the catalog already advertised it. Add a logfire type modeled on the Grafana flow: two secret steps quoting the literal Logfire dashboard labels for each key, a new --organization-api-key flag next to --api-key, and a trimmed pair helper that names the missing flag in non-interactive runs. Region is left to the API, which reads it from the key prefix. Co-Authored-By: Claude Fable 5.1 --- src/commands/integration/connect.ts | 62 ++++++++++++++++++++- test/integration-connect-category.test.ts | 10 ++-- test/integration-connect-logfire.test.ts | 66 +++++++++++++++++++++++ 3 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 test/integration-connect-logfire.test.ts diff --git a/src/commands/integration/connect.ts b/src/commands/integration/connect.ts index 820df37..054133c 100644 --- a/src/commands/integration/connect.ts +++ b/src/commands/integration/connect.ts @@ -49,6 +49,7 @@ type ConnectableType = | 'betterstack' | 'openstatus' | 'grafana' + | 'logfire' | 'mixpanel' | 'devin' | 'cursor' @@ -73,6 +74,7 @@ const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string; { value: 'betterstack', label: 'Better Stack', hint: 'global, Uptime and Telemetry tokens', category: 'observability' }, { value: 'openstatus', label: 'OpenStatus', hint: 'workspace API key', category: 'observability' }, { value: 'grafana', label: 'Grafana Cloud', hint: 'stack URL + service account token', category: 'observability' }, + { value: 'logfire', label: 'Logfire', hint: 'project + organization API keys', category: 'observability' }, { value: 'mixpanel', label: 'Mixpanel', hint: 'service account + project ID', category: 'product-analytics' }, { value: 'devin', label: 'Devin', hint: 'API key · coding agent', category: 'code-agent' }, { value: 'cursor', label: 'Cursor', hint: 'API key · coding agent', category: 'code-agent' }, @@ -618,6 +620,21 @@ export function assertHoneycombManagementKeyStored( ); } +const LOGFIRE_KEY_PAIR_HINT = 'Pass both --api-key (the project key) and --organization-api-key (the organization key).'; + +// Logfire issues the query scope on a project key and the notification-channel +// scope on an organization key, so a connect needs both. Both are trimmed here +// so the flag and prompt paths behave identically on pasted values. +export function logfireKeyFields(apiKey: string, organizationApiKey: string): { apiKey: string; organizationApiKey: string } { + const projectKey = apiKey.trim(); + const organizationKey = organizationApiKey.trim(); + if (!projectKey || !organizationKey) { + const missing = !projectKey ? '--api-key' : '--organization-api-key'; + throw new CLIError(`Missing required flag: ${missing}`, ExitCode.USAGE, LOGFIRE_KEY_PAIR_HINT); + } + return { apiKey: projectKey, organizationApiKey: organizationKey }; +} + // --- Credential-based connects: each wizard step can go back to the previous // one, and backing out of the first returns BACK to re-open type selection --- async function connectWithCredentials( @@ -998,6 +1015,45 @@ async function connectWithCredentials( ]); if (!ok) return BACK; body = { type: 'grafana', workspaceId, stackUrl, serviceAccountToken }; + } else if (type === 'logfire') { + let apiKey = ''; + let organizationApiKey = ''; + const ok = await runSteps([ + secretStep( + config, + args, + 'apiKey', + '--api-key', + { + message: 'Logfire project API key', + instructions: + 'In Logfire, open your project, then Project settings > API keys > New API key. Check Read-only access, keep Key type as Project, then Create API key. Agents use it to query the traces and metrics of this project. The key starts with pylf_ and the region (US or EU) is read from its prefix.', + link: 'https://logfire.pydantic.dev', + linkLabel: 'Open Logfire', + }, + (v) => { + apiKey = v; + } + ), + secretStep( + config, + args, + 'organizationApiKey', + '--organization-api-key', + { + message: 'Logfire organization API key', + instructions: + 'From Project settings choose Go to org settings, then API keys > New API key. Check Read-only access and Manage project and org, set Key type to Organization, then Create API key. Polylane uses it to add its notification channel to the alerts of this project, so it hears about a problem the moment an alert fires. The same organization key works for every project in the organization.', + link: 'https://logfire.pydantic.dev', + linkLabel: 'Open Logfire', + }, + (v) => { + organizationApiKey = v; + } + ), + ]); + if (!ok) return BACK; + body = { type: 'logfire', workspaceId, ...logfireKeyFields(apiKey, organizationApiKey) }; } else if (type === 'linear') { let apiKey = ''; const ok = await runSteps([ @@ -1120,7 +1176,7 @@ async function connectType( export const integrationConnectCommand: Command = { name: 'integration connect', - description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, OpenStatus, Grafana Cloud, Mixpanel, Devin, Cursor, Factory, Conductor, Linear, MCP)', + description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, OpenStatus, Grafana Cloud, Logfire, Mixpanel, Devin, Cursor, Factory, Conductor, Linear, MCP)', operationId: 'integrations.connect', options: [ { @@ -1135,8 +1191,9 @@ export const integrationConnectCommand: Command = { }, { flag: '--site ', description: 'Datadog site (e.g. us5.datadoghq.com)', type: 'string' }, { flag: '--region ', description: 'Honeycomb (us|eu), Axiom (us-east-1|eu-central-1; detected from the token if omitted) or Mixpanel (us|eu|in)', type: 'string' }, - { flag: '--api-key ', description: 'API key (Datadog / Honeycomb / OpenStatus / Devin / Cursor / Factory / Conductor / Linear)', type: 'string' }, + { flag: '--api-key ', description: 'API key (Datadog / Honeycomb / OpenStatus / Logfire project key / Devin / Cursor / Factory / Conductor / Linear)', type: 'string' }, { flag: '--app-key ', description: 'App key (Datadog only)', type: 'string' }, + { flag: '--organization-api-key ', description: 'Organization API key (Logfire only, pylf_...)', type: 'string' }, { flag: '--management-api-key-id ', description: 'Management API key ID (Honeycomb)', type: 'string' }, { flag: '--management-api-key-secret ', description: 'Management API key secret (Honeycomb)', type: 'string' }, { flag: '--api-token ', description: 'API token (Axiom / Better Stack global token)', type: 'string' }, @@ -1180,6 +1237,7 @@ export const integrationConnectCommand: Command = { 'polylane integration connect --type betterstack --api-token ... --uptime-api-token ... --telemetry-api-token ...', 'polylane integration connect --type openstatus --api-key ...', 'polylane integration connect --type grafana --stack-url https://mystack.grafana.net --service-account-token glsa_...', + 'polylane integration connect --type logfire --api-key pylf_... --organization-api-key pylf_...', 'polylane integration connect --type mixpanel --region us --service-account-username ... --service-account-secret ... --project-id 1234567', 'polylane integration connect --type cursor --api-key crsr_...', 'polylane integration connect --type linear --api-key lin_api_...', diff --git a/test/integration-connect-category.test.ts b/test/integration-connect-category.test.ts index 7ec4853..912945c 100644 --- a/test/integration-connect-category.test.ts +++ b/test/integration-connect-category.test.ts @@ -10,12 +10,12 @@ import { isCLIError } from '../src/errors/base'; describe('typeOptionsForCategory', () => { it('returns every option when no category is given', () => { const all = typeOptionsForCategory(undefined); - assert.equal(all.length, 16); + assert.equal(all.length, 17); }); it('narrows to exactly the observability integrations', () => { const types = typeOptionsForCategory('observability').map((o) => o.value); - assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'grafana', 'honeycomb', 'openstatus', 'sentry']); + assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'grafana', 'honeycomb', 'logfire', 'openstatus', 'sentry']); }); it('narrows to exactly the product analytics integrations', () => { @@ -69,9 +69,9 @@ describe('typeOptionsForCategory', () => { describe('resolveTypeOptions', () => { it('lets --type win over the filter', () => { - assert.equal(resolveTypeOptions('observability', true).length, 16); - assert.equal(resolveTypeOptions('observability', false).length, 7); - assert.equal(resolveTypeOptions(undefined, false).length, 16); + assert.equal(resolveTypeOptions('observability', true).length, 17); + assert.equal(resolveTypeOptions('observability', false).length, 8); + assert.equal(resolveTypeOptions(undefined, false).length, 17); }); it('rejects an unknown category even when --type is present', () => { diff --git a/test/integration-connect-logfire.test.ts b/test/integration-connect-logfire.test.ts new file mode 100644 index 0000000..d8281c9 --- /dev/null +++ b/test/integration-connect-logfire.test.ts @@ -0,0 +1,66 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { logfireKeyFields, typeOptionsForCategory } from '../src/commands/integration/connect'; +import { CLIError } from '../src/errors/base'; +import { ExitCode } from '../src/errors/codes'; + +describe('logfireKeyFields', () => { + it('returns both keys when both are set', () => { + assert.deepEqual(logfireKeyFields('pylf_v1_us_project', 'pylf_v1_us_org'), { + apiKey: 'pylf_v1_us_project', + organizationApiKey: 'pylf_v1_us_org', + }); + }); + + it('trims surrounding whitespace from both keys', () => { + assert.deepEqual(logfireKeyFields(' pylf_v1_us_project\n', '\tpylf_v1_us_org '), { + apiKey: 'pylf_v1_us_project', + organizationApiKey: 'pylf_v1_us_org', + }); + }); + + it('names --api-key when the project key is missing', () => { + assert.throws( + () => logfireKeyFields('', 'pylf_v1_us_org'), + (err: unknown) => + err instanceof CLIError && err.exitCode === ExitCode.USAGE && err.message.includes('--api-key') + ); + assert.throws( + () => logfireKeyFields(' ', 'pylf_v1_us_org'), + (err: unknown) => err instanceof CLIError && err.message.includes('--api-key') + ); + }); + + it('names --organization-api-key when the organization key is missing', () => { + assert.throws( + () => logfireKeyFields('pylf_v1_us_project', ''), + (err: unknown) => + err instanceof CLIError && err.exitCode === ExitCode.USAGE && err.message.includes('--organization-api-key') + ); + assert.throws( + () => logfireKeyFields('pylf_v1_us_project', ' \n'), + (err: unknown) => err instanceof CLIError && err.message.includes('--organization-api-key') + ); + }); + + it('reports the project key first when both are missing', () => { + assert.throws( + () => logfireKeyFields('', ''), + (err: unknown) => err instanceof CLIError && err.message.includes('--api-key') + ); + }); +}); + +describe('Logfire type option', () => { + it('is offered under the observability category', () => { + const logfire = typeOptionsForCategory('observability').find((o) => o.value === 'logfire'); + assert.ok(logfire); + assert.equal(logfire.label, 'Logfire'); + }); + + it('is not offered under other categories', () => { + for (const category of ['git', 'communication', 'product-analytics', 'code-agent', 'issue-tracking', 'protocol']) { + assert.equal(typeOptionsForCategory(category).some((o) => o.value === 'logfire'), false, category); + } + }); +});