Skip to content

Commit fc184e7

Browse files
nwm-54claude
andauthored
feat(integration): connect Logfire with a project key and an organization key (#88)
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 <noreply@anthropic.com>
1 parent 141c04d commit fc184e7

3 files changed

Lines changed: 131 additions & 7 deletions

File tree

‎src/commands/integration/connect.ts‎

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ type ConnectableType =
4949
| 'betterstack'
5050
| 'openstatus'
5151
| 'grafana'
52+
| 'logfire'
5253
| 'mixpanel'
5354
| 'devin'
5455
| 'cursor'
@@ -73,6 +74,7 @@ const TYPE_OPTIONS: Array<{ value: ConnectableType; label: string; hint: string;
7374
{ value: 'betterstack', label: 'Better Stack', hint: 'global, Uptime and Telemetry tokens', category: 'observability' },
7475
{ value: 'openstatus', label: 'OpenStatus', hint: 'workspace API key', category: 'observability' },
7576
{ value: 'grafana', label: 'Grafana Cloud', hint: 'stack URL + service account token', category: 'observability' },
77+
{ value: 'logfire', label: 'Logfire', hint: 'project + organization API keys', category: 'observability' },
7678
{ value: 'mixpanel', label: 'Mixpanel', hint: 'service account + project ID', category: 'product-analytics' },
7779
{ value: 'devin', label: 'Devin', hint: 'API key · coding agent', category: 'code-agent' },
7880
{ value: 'cursor', label: 'Cursor', hint: 'API key · coding agent', category: 'code-agent' },
@@ -618,6 +620,21 @@ export function assertHoneycombManagementKeyStored(
618620
);
619621
}
620622

623+
const LOGFIRE_KEY_PAIR_HINT = 'Pass both --api-key (the project key) and --organization-api-key (the organization key).';
624+
625+
// Logfire issues the query scope on a project key and the notification-channel
626+
// scope on an organization key, so a connect needs both. Both are trimmed here
627+
// so the flag and prompt paths behave identically on pasted values.
628+
export function logfireKeyFields(apiKey: string, organizationApiKey: string): { apiKey: string; organizationApiKey: string } {
629+
const projectKey = apiKey.trim();
630+
const organizationKey = organizationApiKey.trim();
631+
if (!projectKey || !organizationKey) {
632+
const missing = !projectKey ? '--api-key' : '--organization-api-key';
633+
throw new CLIError(`Missing required flag: ${missing}`, ExitCode.USAGE, LOGFIRE_KEY_PAIR_HINT);
634+
}
635+
return { apiKey: projectKey, organizationApiKey: organizationKey };
636+
}
637+
621638
// --- Credential-based connects: each wizard step can go back to the previous
622639
// one, and backing out of the first returns BACK to re-open type selection ---
623640
async function connectWithCredentials(
@@ -998,6 +1015,45 @@ async function connectWithCredentials(
9981015
]);
9991016
if (!ok) return BACK;
10001017
body = { type: 'grafana', workspaceId, stackUrl, serviceAccountToken };
1018+
} else if (type === 'logfire') {
1019+
let apiKey = '';
1020+
let organizationApiKey = '';
1021+
const ok = await runSteps([
1022+
secretStep(
1023+
config,
1024+
args,
1025+
'apiKey',
1026+
'--api-key',
1027+
{
1028+
message: 'Logfire project API key',
1029+
instructions:
1030+
'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.',
1031+
link: 'https://logfire.pydantic.dev',
1032+
linkLabel: 'Open Logfire',
1033+
},
1034+
(v) => {
1035+
apiKey = v;
1036+
}
1037+
),
1038+
secretStep(
1039+
config,
1040+
args,
1041+
'organizationApiKey',
1042+
'--organization-api-key',
1043+
{
1044+
message: 'Logfire organization API key',
1045+
instructions:
1046+
'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.',
1047+
link: 'https://logfire.pydantic.dev',
1048+
linkLabel: 'Open Logfire',
1049+
},
1050+
(v) => {
1051+
organizationApiKey = v;
1052+
}
1053+
),
1054+
]);
1055+
if (!ok) return BACK;
1056+
body = { type: 'logfire', workspaceId, ...logfireKeyFields(apiKey, organizationApiKey) };
10011057
} else if (type === 'linear') {
10021058
let apiKey = '';
10031059
const ok = await runSteps([
@@ -1120,7 +1176,7 @@ async function connectType(
11201176

11211177
export const integrationConnectCommand: Command = {
11221178
name: 'integration connect',
1123-
description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, OpenStatus, Grafana Cloud, Mixpanel, Devin, Cursor, Factory, Conductor, Linear, MCP)',
1179+
description: 'Connect an integration (GitHub, Slack, Sentry, Datadog, Honeycomb, Axiom, Better Stack, OpenStatus, Grafana Cloud, Logfire, Mixpanel, Devin, Cursor, Factory, Conductor, Linear, MCP)',
11241180
operationId: 'integrations.connect',
11251181
options: [
11261182
{
@@ -1135,8 +1191,9 @@ export const integrationConnectCommand: Command = {
11351191
},
11361192
{ flag: '--site <site>', description: 'Datadog site (e.g. us5.datadoghq.com)', type: 'string' },
11371193
{ flag: '--region <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' },
1138-
{ flag: '--api-key <key>', description: 'API key (Datadog / Honeycomb / OpenStatus / Devin / Cursor / Factory / Conductor / Linear)', type: 'string' },
1194+
{ flag: '--api-key <key>', description: 'API key (Datadog / Honeycomb / OpenStatus / Logfire project key / Devin / Cursor / Factory / Conductor / Linear)', type: 'string' },
11391195
{ flag: '--app-key <key>', description: 'App key (Datadog only)', type: 'string' },
1196+
{ flag: '--organization-api-key <key>', description: 'Organization API key (Logfire only, pylf_...)', type: 'string' },
11401197
{ flag: '--management-api-key-id <id>', description: 'Management API key ID (Honeycomb)', type: 'string' },
11411198
{ flag: '--management-api-key-secret <secret>', description: 'Management API key secret (Honeycomb)', type: 'string' },
11421199
{ flag: '--api-token <token>', description: 'API token (Axiom / Better Stack global token)', type: 'string' },
@@ -1180,6 +1237,7 @@ export const integrationConnectCommand: Command = {
11801237
'polylane integration connect --type betterstack --api-token ... --uptime-api-token ... --telemetry-api-token ...',
11811238
'polylane integration connect --type openstatus --api-key ...',
11821239
'polylane integration connect --type grafana --stack-url https://mystack.grafana.net --service-account-token glsa_...',
1240+
'polylane integration connect --type logfire --api-key pylf_... --organization-api-key pylf_...',
11831241
'polylane integration connect --type mixpanel --region us --service-account-username ... --service-account-secret ... --project-id 1234567',
11841242
'polylane integration connect --type cursor --api-key crsr_...',
11851243
'polylane integration connect --type linear --api-key lin_api_...',

‎test/integration-connect-category.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,12 @@ import { isCLIError } from '../src/errors/base';
1010
describe('typeOptionsForCategory', () => {
1111
it('returns every option when no category is given', () => {
1212
const all = typeOptionsForCategory(undefined);
13-
assert.equal(all.length, 16);
13+
assert.equal(all.length, 17);
1414
});
1515

1616
it('narrows to exactly the observability integrations', () => {
1717
const types = typeOptionsForCategory('observability').map((o) => o.value);
18-
assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'grafana', 'honeycomb', 'openstatus', 'sentry']);
18+
assert.deepEqual(types.sort(), ['axiom', 'betterstack', 'datadog', 'grafana', 'honeycomb', 'logfire', 'openstatus', 'sentry']);
1919
});
2020

2121
it('narrows to exactly the product analytics integrations', () => {
@@ -69,9 +69,9 @@ describe('typeOptionsForCategory', () => {
6969

7070
describe('resolveTypeOptions', () => {
7171
it('lets --type win over the filter', () => {
72-
assert.equal(resolveTypeOptions('observability', true).length, 16);
73-
assert.equal(resolveTypeOptions('observability', false).length, 7);
74-
assert.equal(resolveTypeOptions(undefined, false).length, 16);
72+
assert.equal(resolveTypeOptions('observability', true).length, 17);
73+
assert.equal(resolveTypeOptions('observability', false).length, 8);
74+
assert.equal(resolveTypeOptions(undefined, false).length, 17);
7575
});
7676

7777
it('rejects an unknown category even when --type is present', () => {
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, it } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { logfireKeyFields, typeOptionsForCategory } from '../src/commands/integration/connect';
4+
import { CLIError } from '../src/errors/base';
5+
import { ExitCode } from '../src/errors/codes';
6+
7+
describe('logfireKeyFields', () => {
8+
it('returns both keys when both are set', () => {
9+
assert.deepEqual(logfireKeyFields('pylf_v1_us_project', 'pylf_v1_us_org'), {
10+
apiKey: 'pylf_v1_us_project',
11+
organizationApiKey: 'pylf_v1_us_org',
12+
});
13+
});
14+
15+
it('trims surrounding whitespace from both keys', () => {
16+
assert.deepEqual(logfireKeyFields(' pylf_v1_us_project\n', '\tpylf_v1_us_org '), {
17+
apiKey: 'pylf_v1_us_project',
18+
organizationApiKey: 'pylf_v1_us_org',
19+
});
20+
});
21+
22+
it('names --api-key when the project key is missing', () => {
23+
assert.throws(
24+
() => logfireKeyFields('', 'pylf_v1_us_org'),
25+
(err: unknown) =>
26+
err instanceof CLIError && err.exitCode === ExitCode.USAGE && err.message.includes('--api-key')
27+
);
28+
assert.throws(
29+
() => logfireKeyFields(' ', 'pylf_v1_us_org'),
30+
(err: unknown) => err instanceof CLIError && err.message.includes('--api-key')
31+
);
32+
});
33+
34+
it('names --organization-api-key when the organization key is missing', () => {
35+
assert.throws(
36+
() => logfireKeyFields('pylf_v1_us_project', ''),
37+
(err: unknown) =>
38+
err instanceof CLIError && err.exitCode === ExitCode.USAGE && err.message.includes('--organization-api-key')
39+
);
40+
assert.throws(
41+
() => logfireKeyFields('pylf_v1_us_project', ' \n'),
42+
(err: unknown) => err instanceof CLIError && err.message.includes('--organization-api-key')
43+
);
44+
});
45+
46+
it('reports the project key first when both are missing', () => {
47+
assert.throws(
48+
() => logfireKeyFields('', ''),
49+
(err: unknown) => err instanceof CLIError && err.message.includes('--api-key')
50+
);
51+
});
52+
});
53+
54+
describe('Logfire type option', () => {
55+
it('is offered under the observability category', () => {
56+
const logfire = typeOptionsForCategory('observability').find((o) => o.value === 'logfire');
57+
assert.ok(logfire);
58+
assert.equal(logfire.label, 'Logfire');
59+
});
60+
61+
it('is not offered under other categories', () => {
62+
for (const category of ['git', 'communication', 'product-analytics', 'code-agent', 'issue-tracking', 'protocol']) {
63+
assert.equal(typeOptionsForCategory(category).some((o) => o.value === 'logfire'), false, category);
64+
}
65+
});
66+
});

0 commit comments

Comments
 (0)