diff --git a/README.md b/README.md index c15185d8..f89e7c9c 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started. - **AI Gateway**: Generate Text, Generate Image +- **Axiom**: Query Logs, Ingest Events, Create Annotation, List Datasets - **Blob**: Put Blob, List Blobs - **Firecrawl**: Scrape URL, Search Web - **GitHub**: Create Issue, List Issues, Get Issue, Update Issue diff --git a/plugins/axiom/credentials.ts b/plugins/axiom/credentials.ts new file mode 100644 index 00000000..b68ef36e --- /dev/null +++ b/plugins/axiom/credentials.ts @@ -0,0 +1,4 @@ +export type AxiomCredentials = { + AXIOM_TOKEN?: string; + AXIOM_ORG_ID?: string; +}; diff --git a/plugins/axiom/icon.tsx b/plugins/axiom/icon.tsx new file mode 100644 index 00000000..b90f9ab8 --- /dev/null +++ b/plugins/axiom/icon.tsx @@ -0,0 +1,14 @@ +export function AxiomIcon({ className }: { className?: string }) { + return ( + + Axiom + + + ); +} diff --git a/plugins/axiom/index.ts b/plugins/axiom/index.ts new file mode 100644 index 00000000..98fda4e3 --- /dev/null +++ b/plugins/axiom/index.ts @@ -0,0 +1,201 @@ +import type { IntegrationPlugin } from "../registry"; +import { registerIntegration } from "../registry"; +import { AxiomIcon } from "./icon"; + +const axiomPlugin: IntegrationPlugin = { + type: "axiom", + label: "Axiom", + description: "Query logs, ingest events, and create annotations in Axiom", + + icon: AxiomIcon, + + formFields: [ + { + id: "token", + label: "API Token", + type: "password", + placeholder: "xaat-...", + configKey: "token", + envVar: "AXIOM_TOKEN", + helpText: "Get your API token from ", + helpLink: { + text: "app.axiom.co/settings/api-tokens", + url: "https://app.axiom.co/settings/api-tokens", + }, + }, + { + id: "orgId", + label: "Organization ID", + type: "text", + placeholder: "my-org-123", + configKey: "orgId", + envVar: "AXIOM_ORG_ID", + helpText: "Required for personal tokens. Find it in your org settings.", + }, + ], + + testConfig: { + getTestFunction: async () => { + const { testAxiom } = await import("./test"); + return testAxiom; + }, + }, + + actions: [ + { + slug: "query-logs", + label: "Query Logs", + description: "Run an APL query against an Axiom dataset", + category: "Axiom", + stepFunction: "queryLogsStep", + stepImportPath: "query-logs", + outputFields: [ + { field: "matches", description: "Array of matching log entries" }, + { field: "count", description: "Number of results returned" }, + { field: "status.elapsedTime", description: "Query execution time" }, + ], + configFields: [ + { + key: "dataset", + label: "Dataset", + type: "template-input", + placeholder: "my-dataset", + example: "vercel", + required: true, + }, + { + key: "apl", + label: "APL Query", + type: "template-textarea", + placeholder: + "['my-dataset'] | where level == 'error' | limit 100", + example: "['vercel'] | where level == 'error' | limit 10", + rows: 4, + required: true, + }, + { + key: "startTime", + label: "Start Time", + type: "template-input", + placeholder: "2024-01-01T00:00:00Z or -1h", + example: "-1h", + }, + { + key: "endTime", + label: "End Time", + type: "template-input", + placeholder: "2024-01-01T23:59:59Z or now", + example: "now", + }, + ], + }, + { + slug: "ingest-events", + label: "Ingest Events", + description: "Send log events to an Axiom dataset", + category: "Axiom", + stepFunction: "ingestEventsStep", + stepImportPath: "ingest-events", + outputFields: [ + { field: "ingested", description: "Number of events ingested" }, + { field: "processedBytes", description: "Bytes processed" }, + ], + configFields: [ + { + key: "dataset", + label: "Dataset", + type: "template-input", + placeholder: "my-dataset", + example: "workflow-logs", + required: true, + }, + { + key: "events", + label: "Events (JSON)", + type: "template-textarea", + placeholder: + '[{"level": "info", "message": "Hello"}] or {{NodeName.data}}', + example: '[{"level": "info", "message": "Workflow executed"}]', + rows: 6, + required: true, + }, + ], + }, + { + slug: "create-annotation", + label: "Create Annotation", + description: + "Create an annotation to mark deployments, incidents, or events", + category: "Axiom", + stepFunction: "createAnnotationStep", + stepImportPath: "create-annotation", + outputFields: [ + { field: "id", description: "Annotation ID" }, + { field: "time", description: "Annotation timestamp" }, + ], + configFields: [ + { + key: "datasets", + label: "Datasets", + type: "template-input", + placeholder: "dataset1,dataset2", + example: "vercel,api-logs", + required: true, + }, + { + key: "type", + label: "Type", + type: "select", + options: [ + { value: "deploy", label: "Deployment" }, + { value: "incident", label: "Incident" }, + { value: "config-change", label: "Config Change" }, + { value: "alert", label: "Alert" }, + { value: "other", label: "Other" }, + ], + defaultValue: "deploy", + }, + { + key: "title", + label: "Title", + type: "template-input", + placeholder: "Production deployment v1.2.3", + example: "Deployed v1.2.3", + required: true, + }, + { + key: "description", + label: "Description", + type: "template-textarea", + placeholder: "Additional details about this annotation", + example: "Deployed new feature: user authentication", + rows: 3, + }, + { + key: "url", + label: "URL", + type: "template-input", + placeholder: "https://github.com/org/repo/releases/tag/v1.2.3", + example: "https://github.com/myorg/myrepo/releases", + }, + ], + }, + { + slug: "list-datasets", + label: "List Datasets", + description: "Get all available datasets in your Axiom organization", + category: "Axiom", + stepFunction: "listDatasetsStep", + stepImportPath: "list-datasets", + outputFields: [ + { field: "datasets", description: "Array of dataset objects" }, + { field: "count", description: "Number of datasets" }, + ], + configFields: [], + }, + ], +}; + +registerIntegration(axiomPlugin); + +export default axiomPlugin; diff --git a/plugins/axiom/steps/create-annotation.ts b/plugins/axiom/steps/create-annotation.ts new file mode 100644 index 00000000..036aafc4 --- /dev/null +++ b/plugins/axiom/steps/create-annotation.ts @@ -0,0 +1,146 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { AxiomCredentials } from "../credentials"; + +const AXIOM_API_URL = "https://api.axiom.co"; + +type AxiomAnnotationResponse = { + id: string; + datasets: string[]; + type: string; + title: string; + description?: string; + url?: string; + time: string; + endTime?: string; +}; + +type CreateAnnotationResult = + | { + success: true; + id: string; + time: string; + datasets: string[]; + } + | { success: false; error: string }; + +export type CreateAnnotationCoreInput = { + datasets: string; + type: string; + title: string; + description?: string; + url?: string; +}; + +export type CreateAnnotationInput = StepInput & + CreateAnnotationCoreInput & { + integrationId?: string; + }; + +async function stepHandler( + input: CreateAnnotationCoreInput, + credentials: AxiomCredentials +): Promise { + const token = credentials.AXIOM_TOKEN; + + if (!token) { + return { + success: false, + error: + "AXIOM_TOKEN is not configured. Please add it in Project Integrations.", + }; + } + + try { + // Parse comma-separated datasets + const datasets = input.datasets + .split(",") + .map((d) => d.trim()) + .filter(Boolean); + + if (datasets.length === 0) { + return { + success: false, + error: "At least one dataset is required", + }; + } + + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + if (credentials.AXIOM_ORG_ID) { + headers["X-Axiom-Org-Id"] = credentials.AXIOM_ORG_ID; + } + + const body: Record = { + datasets, + type: input.type || "deploy", + title: input.title, + time: new Date().toISOString(), + }; + + if (input.description) { + body.description = input.description; + } + + if (input.url) { + body.url = input.url; + } + + const response = await fetch(`${AXIOM_API_URL}/v2/annotations`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText) as { message?: string }; + errorMessage = errorJson.message || errorMessage; + } catch { + if (errorText) { + errorMessage = errorText; + } + } + return { + success: false, + error: `Failed to create annotation: ${errorMessage}`, + }; + } + + const result = (await response.json()) as AxiomAnnotationResponse; + + return { + success: true, + id: result.id, + time: result.time, + datasets: result.datasets, + }; + } catch (error) { + return { + success: false, + error: `Failed to create annotation: ${getErrorMessage(error)}`, + }; + } +} + +export async function createAnnotationStep( + input: CreateAnnotationInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "axiom"; diff --git a/plugins/axiom/steps/ingest-events.ts b/plugins/axiom/steps/ingest-events.ts new file mode 100644 index 00000000..126c791a --- /dev/null +++ b/plugins/axiom/steps/ingest-events.ts @@ -0,0 +1,152 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { AxiomCredentials } from "../credentials"; + +const AXIOM_API_URL = "https://api.axiom.co"; + +type AxiomIngestResponse = { + ingested: number; + failed: number; + failures?: Array<{ timestamp: string; error: string }>; + processedBytes: number; + blocksCreated: number; + walLength: number; +}; + +type IngestEventsResult = + | { + success: true; + ingested: number; + failed: number; + processedBytes: number; + } + | { success: false; error: string }; + +export type IngestEventsCoreInput = { + dataset: string; + events: string; +}; + +export type IngestEventsInput = StepInput & + IngestEventsCoreInput & { + integrationId?: string; + }; + +async function stepHandler( + input: IngestEventsCoreInput, + credentials: AxiomCredentials +): Promise { + const token = credentials.AXIOM_TOKEN; + + if (!token) { + return { + success: false, + error: + "AXIOM_TOKEN is not configured. Please add it in Project Integrations.", + }; + } + + try { + // Parse the events JSON string + let events: Array>; + try { + const parsed = JSON.parse(input.events) as unknown; + // Support both single object and array + events = Array.isArray(parsed) ? parsed : [parsed]; + } catch { + return { + success: false, + error: + "Invalid JSON in events field. Expected an array of objects or a single object.", + }; + } + + if (events.length === 0) { + return { + success: false, + error: "No events to ingest. Events array is empty.", + }; + } + + // Add timestamp to events that don't have one + const eventsWithTimestamp = events.map((event) => { + if (!event._time && !event.timestamp) { + return { ...event, _time: new Date().toISOString() }; + } + return event; + }); + + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + if (credentials.AXIOM_ORG_ID) { + headers["X-Axiom-Org-Id"] = credentials.AXIOM_ORG_ID; + } + + const response = await fetch( + `${AXIOM_API_URL}/v1/datasets/${encodeURIComponent(input.dataset)}/ingest`, + { + method: "POST", + headers, + body: JSON.stringify(eventsWithTimestamp), + } + ); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText) as { message?: string }; + errorMessage = errorJson.message || errorMessage; + } catch { + if (errorText) { + errorMessage = errorText; + } + } + return { + success: false, + error: `Ingest failed: ${errorMessage}`, + }; + } + + const result = (await response.json()) as AxiomIngestResponse; + + if (result.failed > 0 && result.failures?.length) { + return { + success: false, + error: `Ingest partially failed: ${result.failures[0].error}`, + }; + } + + return { + success: true, + ingested: result.ingested, + failed: result.failed, + processedBytes: result.processedBytes, + }; + } catch (error) { + return { + success: false, + error: `Failed to ingest events: ${getErrorMessage(error)}`, + }; + } +} + +export async function ingestEventsStep( + input: IngestEventsInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "axiom"; diff --git a/plugins/axiom/steps/list-datasets.ts b/plugins/axiom/steps/list-datasets.ts new file mode 100644 index 00000000..62397212 --- /dev/null +++ b/plugins/axiom/steps/list-datasets.ts @@ -0,0 +1,118 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { AxiomCredentials } from "../credentials"; + +const AXIOM_API_URL = "https://api.axiom.co"; + +type AxiomDataset = { + id: string; + name: string; + description?: string; + who?: string; + created: string; +}; + +type ListDatasetsResult = + | { + success: true; + datasets: Array<{ + id: string; + name: string; + description?: string; + created: string; + }>; + count: number; + } + | { success: false; error: string }; + +export type ListDatasetsCoreInput = Record; + +export type ListDatasetsInput = StepInput & + ListDatasetsCoreInput & { + integrationId?: string; + }; + +async function stepHandler( + _input: ListDatasetsCoreInput, + credentials: AxiomCredentials +): Promise { + const token = credentials.AXIOM_TOKEN; + + if (!token) { + return { + success: false, + error: + "AXIOM_TOKEN is not configured. Please add it in Project Integrations.", + }; + } + + try { + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + if (credentials.AXIOM_ORG_ID) { + headers["X-Axiom-Org-Id"] = credentials.AXIOM_ORG_ID; + } + + const response = await fetch(`${AXIOM_API_URL}/v1/datasets`, { + method: "GET", + headers, + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText) as { message?: string }; + errorMessage = errorJson.message || errorMessage; + } catch { + if (errorText) { + errorMessage = errorText; + } + } + return { + success: false, + error: `Failed to list datasets: ${errorMessage}`, + }; + } + + const rawDatasets = (await response.json()) as AxiomDataset[]; + + const datasets = rawDatasets.map((ds) => ({ + id: ds.id, + name: ds.name, + description: ds.description, + created: ds.created, + })); + + return { + success: true, + datasets, + count: datasets.length, + }; + } catch (error) { + return { + success: false, + error: `Failed to list datasets: ${getErrorMessage(error)}`, + }; + } +} + +export async function listDatasetsStep( + input: ListDatasetsInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler({}, credentials)); +} + +export const _integrationType = "axiom"; diff --git a/plugins/axiom/steps/query-logs.ts b/plugins/axiom/steps/query-logs.ts new file mode 100644 index 00000000..e60e17df --- /dev/null +++ b/plugins/axiom/steps/query-logs.ts @@ -0,0 +1,171 @@ +import "server-only"; + +import { fetchCredentials } from "@/lib/credential-fetcher"; +import { type StepInput, withStepLogging } from "@/lib/steps/step-handler"; +import { getErrorMessage } from "@/lib/utils"; +import type { AxiomCredentials } from "../credentials"; + +const AXIOM_API_URL = "https://api.axiom.co"; + +type AxiomQueryStatus = { + elapsedTime: number; + blocksExamined: number; + rowsExamined: number; + rowsMatched: number; + numGroups: number; + isPartial: boolean; + minBlockTime: string; + maxBlockTime: string; +}; + +type AxiomQueryResponse = { + status: AxiomQueryStatus; + matches: Array>; + buckets?: { + totals?: Array>; + }; +}; + +type QueryLogsResult = + | { + success: true; + matches: Array>; + count: number; + status: AxiomQueryStatus; + } + | { success: false; error: string }; + +export type QueryLogsCoreInput = { + dataset: string; + apl: string; + startTime?: string; + endTime?: string; +}; + +export type QueryLogsInput = StepInput & + QueryLogsCoreInput & { + integrationId?: string; + }; + +function parseRelativeTime(time: string): string { + if (!time || time === "now") { + return new Date().toISOString(); + } + + // Handle relative time like -1h, -30m, -7d + const match = time.match(/^-(\d+)([mhdw])$/); + if (match) { + const value = parseInt(match[1], 10); + const unit = match[2]; + const now = new Date(); + + switch (unit) { + case "m": + now.setMinutes(now.getMinutes() - value); + break; + case "h": + now.setHours(now.getHours() - value); + break; + case "d": + now.setDate(now.getDate() - value); + break; + case "w": + now.setDate(now.getDate() - value * 7); + break; + } + + return now.toISOString(); + } + + // Assume it's already an ISO date string + return time; +} + +async function stepHandler( + input: QueryLogsCoreInput, + credentials: AxiomCredentials +): Promise { + const token = credentials.AXIOM_TOKEN; + + if (!token) { + return { + success: false, + error: + "AXIOM_TOKEN is not configured. Please add it in Project Integrations.", + }; + } + + try { + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + if (credentials.AXIOM_ORG_ID) { + headers["X-Axiom-Org-Id"] = credentials.AXIOM_ORG_ID; + } + + const body: Record = { + apl: input.apl, + }; + + if (input.startTime) { + body.startTime = parseRelativeTime(input.startTime); + } + + if (input.endTime) { + body.endTime = parseRelativeTime(input.endTime); + } + + const response = await fetch(`${AXIOM_API_URL}/v1/datasets/_apl`, { + method: "POST", + headers, + body: JSON.stringify(body), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText) as { message?: string }; + errorMessage = errorJson.message || errorMessage; + } catch { + if (errorText) { + errorMessage = errorText; + } + } + return { + success: false, + error: `Query failed: ${errorMessage}`, + }; + } + + const result = (await response.json()) as AxiomQueryResponse; + + return { + success: true, + matches: result.matches || [], + count: result.matches?.length || 0, + status: result.status, + }; + } catch (error) { + return { + success: false, + error: `Failed to query logs: ${getErrorMessage(error)}`, + }; + } +} + +export async function queryLogsStep( + input: QueryLogsInput +): Promise { + "use step"; + + const credentials = input.integrationId + ? await fetchCredentials(input.integrationId) + : {}; + + return withStepLogging(input, () => stepHandler(input, credentials)); +} + +export const _integrationType = "axiom"; diff --git a/plugins/axiom/test.ts b/plugins/axiom/test.ts new file mode 100644 index 00000000..ad09041a --- /dev/null +++ b/plugins/axiom/test.ts @@ -0,0 +1,61 @@ +const AXIOM_API_URL = "https://api.axiom.co"; + +type AxiomUserResponse = { + id: string; + name: string; + email: string; +}; + +export async function testAxiom(credentials: Record) { + try { + const token = credentials.AXIOM_TOKEN; + const orgId = credentials.AXIOM_ORG_ID; + + if (!token) { + return { + success: false, + error: "AXIOM_TOKEN is required", + }; + } + + const headers: Record = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }; + + if (orgId) { + headers["X-Axiom-Org-Id"] = orgId; + } + + const response = await fetch(`${AXIOM_API_URL}/v1/user`, { + method: "GET", + headers, + }); + + if (!response.ok) { + if (response.status === 401) { + return { success: false, error: "Invalid API token" }; + } + if (response.status === 403) { + return { + success: false, + error: "Access denied. Check your token permissions.", + }; + } + return { success: false, error: `API error: HTTP ${response.status}` }; + } + + const user = (await response.json()) as AxiomUserResponse; + + if (!user.id) { + return { success: false, error: "Invalid response from Axiom API" }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/plugins/index.ts b/plugins/index.ts index af795cb5..f4f298ef 100644 --- a/plugins/index.ts +++ b/plugins/index.ts @@ -13,10 +13,11 @@ * 1. Delete the plugin directory * 2. Run: pnpm discover-plugins (or it runs automatically on build) * - * Discovered plugins: ai-gateway, blob, firecrawl, github, linear, resend, slack, superagent, v0 + * Discovered plugins: ai-gateway, axiom, blob, firecrawl, github, linear, resend, slack, superagent, v0 */ import "./ai-gateway"; +import "./axiom"; import "./blob"; import "./firecrawl"; import "./github";