Marginalia MCP Server
by bmorphism
- src
- api
import { z } from "zod";
import {
QueryResult,
AnalysisQuery,
QueryCalculation,
} from "../types/query.js";
import { QueryToolSchema, ColumnAnalysisSchema } from "../types/schema.js";
import { HoneycombConfig } from "../types/config.js";
import { HoneycombError } from "../utils/errors.js";
import { Column } from "../types/column.js";
import { Dataset } from "../types/api.js";
import { SLO, SLODetailedResponse } from "../types/slo.js";
import { TriggerResponse } from "../types/trigger.js";
import { HoneycombEnvironment } from "../types/config.js";
import { QueryOptions } from "../types/api.js";
import { Config } from "../config.js";
export class HoneycombAPI {
private environments: Map<string, { apiKey: string }>;
constructor(config: Config) {
this.environments = new Map(
config.environments.map(env => [env.name, { apiKey: env.apiKey }])
);
}
getEnvironments(): string[] {
return Array.from(this.environments.keys());
}
private getApiKey(environment: string): string {
const env = this.environments.get(environment);
if (!env) {
throw new Error(
`Unknown environment: "${environment}". Available environments: ${Array.from(this.environments.keys()).join(", ")}`
);
}
return env.apiKey;
}
private async request<T>(
environment: string,
path: string,
options: RequestInit & { params?: Record<string, any> } = {},
): Promise<T> {
const apiKey = this.getApiKey(environment);
const { params, ...requestOptions } = options;
let url = `https://api.honeycomb.io${path}`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, String(value));
}
});
url += `?${searchParams.toString()}`;
}
const response = await fetch(url, {
...requestOptions,
headers: {
"X-Honeycomb-Team": apiKey,
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
throw new HoneycombError(
response.status,
`Honeycomb API error: ${response.statusText}`,
);
}
return response.json() as Promise<T>;
}
// Dataset methods
async getDataset(environment: string, datasetSlug: string): Promise<Dataset> {
const apiKey = this.getApiKey(environment);
return this.request(environment, `/1/datasets/${datasetSlug}`);
}
async listDatasets(environment: string): Promise<Dataset[]> {
const apiKey = this.getApiKey(environment);
return this.request(environment, "/1/datasets");
}
// Query methods
async createQuery(
environment: string,
datasetSlug: string,
query: AnalysisQuery,
): Promise<{ id: string }> {
const apiKey = this.getApiKey(environment);
return this.request<{ id: string }>(
environment,
`/1/queries/${datasetSlug}`,
{
method: "POST",
body: JSON.stringify(query),
},
);
}
async createQueryResult(
environment: string,
datasetSlug: string,
queryId: string,
): Promise<{ id: string }> {
const apiKey = this.getApiKey(environment);
return this.request<{ id: string }>(
environment,
`/1/query_results/${datasetSlug}`,
{
method: "POST",
body: JSON.stringify({ query_id: queryId }),
},
);
}
async getQueryResults(
environment: string,
datasetSlug: string,
queryResultId: string,
includeSeries: boolean = false,
): Promise<QueryResult> {
const apiKey = this.getApiKey(environment);
const response = await this.request<QueryResult>(
environment,
`/1/query_results/${datasetSlug}/${queryResultId}`,
{
params: {
include_series: includeSeries,
},
},
);
if (!includeSeries && response.data) {
const { series, ...rest } = response.data;
response.data = rest;
}
return response;
}
async queryAndWaitForResults(
environment: string,
datasetSlug: string,
query: AnalysisQuery,
maxAttempts = 10,
options: QueryOptions = {},
): Promise<QueryResult> {
const apiKey = this.getApiKey(environment);
const defaultLimit = 100;
const queryWithLimit = {
...query,
limit: query.limit || options.limit || defaultLimit,
};
const queryResponse = await this.createQuery(
environment,
datasetSlug,
queryWithLimit,
);
const queryId = queryResponse.id;
const queryResult = await this.createQueryResult(
environment,
datasetSlug,
queryId,
);
const queryResultId = queryResult.id;
let attempts = 0;
while (attempts < maxAttempts) {
const results = await this.getQueryResults(
environment,
datasetSlug,
queryResultId,
options.includeSeries,
);
if (results.complete) {
return results;
}
attempts++;
await new Promise((resolve) => setTimeout(resolve, 1000));
}
throw new Error("Query timed out waiting for results");
}
// Column methods
async getColumns(
environment: string,
datasetSlug: string,
): Promise<Column[]> {
const apiKey = this.getApiKey(environment);
return this.request(environment, `/1/columns/${datasetSlug}`);
}
async getColumnByName(
environment: string,
datasetSlug: string,
keyName: string,
): Promise<Column> {
const apiKey = this.getApiKey(environment);
return this.request(
environment,
`/1/columns/${datasetSlug}?key_name=${encodeURIComponent(keyName)}`,
);
}
async getVisibleColumns(
environment: string,
datasetSlug: string,
): Promise<Column[]> {
const apiKey = this.getApiKey(environment);
const columns = await this.getColumns(environment, datasetSlug);
return columns.filter((column) => !column.hidden);
}
async runAnalysisQuery(
environment: string,
datasetSlug: string,
params: z.infer<typeof QueryToolSchema>,
) {
const apiKey = this.getApiKey(environment);
const query: AnalysisQuery = {
calculations: params.calculations,
breakdowns: params.breakdowns || [],
filters: params.filters,
filter_combination: params.filter_combination,
time_range: params.time_range || 3600,
orders: params.orders,
limit: params.limit,
start_time: params.start_time,
end_time: params.end_time,
granularity: params.granularity,
having: params.having,
};
try {
const results = await this.queryAndWaitForResults(
environment,
datasetSlug,
query,
);
return {
data: {
results: results.data?.results || [],
series: results.data?.series || [],
},
links: results.links,
};
} catch (error) {
throw new Error(
`Analysis query failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
async analyzeColumn(
environment: string,
datasetSlug: string,
params: z.infer<typeof ColumnAnalysisSchema>,
) {
const apiKey = this.getApiKey(environment);
const column = await this.getColumnByName(
environment,
datasetSlug,
params.column,
);
const query: AnalysisQuery = {
calculations: [{ op: "COUNT" }],
breakdowns: [params.column],
time_range: params.timeRange || 3600,
orders: [
{
op: "COUNT",
order: "descending",
},
],
limit: 10,
};
if (column.type === "integer" || column.type === "float") {
const numericCalculations: QueryCalculation[] = [
{ op: "AVG", column: params.column },
{ op: "P95", column: params.column },
{ op: "MAX", column: params.column },
{ op: "MIN", column: params.column },
];
query.calculations.push(...numericCalculations);
}
try {
const results = await this.queryAndWaitForResults(
environment,
datasetSlug,
query,
);
return {
data: {
results: results.data?.results || [],
series: results.data?.series || [],
},
links: results.links,
};
} catch (error) {
throw new Error(
`Column analysis failed: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
}
async getSLOs(environment: string, datasetSlug: string): Promise<SLO[]> {
const apiKey = this.getApiKey(environment);
return this.request<SLO[]>(environment, `/1/slos/${datasetSlug}`);
}
async getSLO(
environment: string,
datasetSlug: string,
sloId: string,
): Promise<SLODetailedResponse> {
const apiKey = this.getApiKey(environment);
return this.request<SLODetailedResponse>(
environment,
`/1/slos/${datasetSlug}/${sloId}`,
{ params: { detailed: true } },
);
}
async getTriggers(
environment: string,
datasetSlug: string,
): Promise<TriggerResponse[]> {
const apiKey = this.getApiKey(environment);
return this.request<TriggerResponse[]>(
environment,
`/1/triggers/${datasetSlug}`,
);
}
async getTrigger(
environment: string,
datasetSlug: string,
triggerId: string,
): Promise<TriggerResponse> {
const apiKey = this.getApiKey(environment);
return this.request<TriggerResponse>(
environment,
`/1/triggers/${datasetSlug}/${triggerId}`,
);
}
}