Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/api/eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export default [
'src/coverage/**',
'migrations/**',
'migrate-mongo-config.ts',
'scripts/**',
'**/*.config.js',
'**/*.config.mjs',
'jest.config.js',
Expand Down
4 changes: 2 additions & 2 deletions packages/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@
},
"scripts": {
"start": "node ./build/index.js",
"dev": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/index.ts",
"dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts",
"dev": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r ./scripts/env-local-preload.js -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/index.ts",
"dev-task": "DOTENV_CONFIG_PATH=.env.development nodemon --exec 'ts-node' --transpile-only -r tsconfig-paths/register -r ./scripts/env-local-preload.js -r dotenv-expand/config -r '@hyperdx/node-opentelemetry/build/src/tracing' ./src/tasks/index.ts",
"build": "rimraf ./build && tsc && tsc-alias && cp -r ./src/opamp/proto ./build/opamp/",
"lint": "npx eslint --quiet . --ext .ts",
"lint:fix": "npx eslint . --ext .ts --fix",
Expand Down
16 changes: 16 additions & 0 deletions packages/api/scripts/env-local-preload.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Preload .env.development.local (if it exists) before dotenv-expand loads
// .env.development. Because dotenv never overwrites existing vars, values
// from the .local file take precedence — matching the Next.js convention.
const fs = require('fs');
const path = require('path');
const dotenv = require('dotenv');

const localPath = path.resolve(
__dirname,
'..',
(process.env.DOTENV_CONFIG_PATH || '.env.development') + '.local',
);

if (fs.existsSync(localPath)) {
dotenv.config({ path: localPath });
}
179 changes: 179 additions & 0 deletions packages/api/src/routers/api/__tests__/aiSummarize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import type { LanguageModel } from 'ai';
import type { NextFunction, Request, Response } from 'express';
import express from 'express';
import request from 'supertest';

// ---------------------------------------------------------------------------
// Mock setup — must precede imports that reference mocked modules
// ---------------------------------------------------------------------------

const mockGenerateText = jest.fn();

jest.mock('ai', () => ({
generateText: (...args: unknown[]) => mockGenerateText(...args),
APICallError: class extends Error {
statusCode: number;
constructor(msg: string, statusCode: number) {
super(msg);
this.name = 'APICallError';
this.statusCode = statusCode;
}
},
Output: { object: jest.fn() },
}));

const mockModel = { modelId: 'test-model' } as unknown as LanguageModel;

jest.mock('@/controllers/ai', () => ({
getAIModel: () => mockModel,
getAIMetadata: jest.fn(),
getChartConfigFromResolvedConfig: jest.fn(),
}));

jest.mock('@/controllers/sources', () => ({
getSource: jest.fn(),
}));

jest.mock('@/middleware/auth', () => ({
getNonNullUserWithTeam: jest.fn().mockReturnValue({
teamId: 'team-123',
}),
}));

jest.mock('@/utils/logger', () => ({
__esModule: true,
default: { info: jest.fn(), warn: jest.fn(), error: jest.fn() },
}));

jest.mock('@/utils/zod', () => ({
objectIdSchema: {
_def: { typeName: 'ZodString' },
parse: (v: string) => v,
},
}));

// ---------------------------------------------------------------------------

import aiRouter from '@/routers/api/ai';
import { BaseError, StatusCode } from '@/utils/errors';

function buildApp() {
const app = express();
app.use(express.json());
app.use('/ai', aiRouter);
// Minimal error handler matching the app's pattern
app.use(
(err: BaseError, _req: Request, res: Response, _next: NextFunction) => {
res
.status(err.statusCode ?? StatusCode.INTERNAL_SERVER)
.json({ message: err.name || err.message });
},
);
return app;
}

describe('POST /ai/summarize', () => {
let app: express.Application;

beforeAll(() => {
app = buildApp();
});

beforeEach(() => {
mockGenerateText.mockReset();
});

it('rejects missing required fields', async () => {
await request(app).post('/ai/summarize').send({}).expect(400);
});

it('rejects invalid type value', async () => {
await request(app)
.post('/ai/summarize')
.send({ type: 'invalid', content: 'hello' })
.expect(400);
});

it('rejects empty content', async () => {
await request(app)
.post('/ai/summarize')
.send({ type: 'event', content: '' })
.expect(400);
});

it('returns summary for event type', async () => {
mockGenerateText.mockResolvedValueOnce({
text: 'This event represents a healthy HTTP GET request.',
});

const res = await request(app)
.post('/ai/summarize')
.send({ type: 'event', content: 'Severity: info\nBody: GET /api/users' })
.expect(200);

expect(res.body).toEqual({
summary: 'This event represents a healthy HTTP GET request.',
});

expect(mockGenerateText).toHaveBeenCalledTimes(1);
const call = mockGenerateText.mock.calls[0][0];
expect(call.model).toBe(mockModel);
expect(call.system).toContain('single log or trace event');
expect(call.prompt).toContain('GET /api/users');
});

it('returns summary for pattern type', async () => {
mockGenerateText.mockResolvedValueOnce({
text: 'This pattern shows repeated database queries.',
});

const res = await request(app)
.post('/ai/summarize')
.send({
type: 'pattern',
content: 'Pattern: SELECT * FROM <*>\nOccurrences: 1500',
})
.expect(200);

expect(res.body).toEqual({
summary: 'This pattern shows repeated database queries.',
});

const call = mockGenerateText.mock.calls[0][0];
expect(call.system).toContain('log/trace pattern');
expect(call.prompt).toContain('SELECT * FROM <*>');
});

it('uses different system prompts for event vs pattern', async () => {
mockGenerateText.mockResolvedValue({ text: 'summary' });

await request(app)
.post('/ai/summarize')
.send({ type: 'event', content: 'test event' });

await request(app)
.post('/ai/summarize')
.send({ type: 'pattern', content: 'test pattern' });

const eventSystem = mockGenerateText.mock.calls[0][0].system;
const patternSystem = mockGenerateText.mock.calls[1][0].system;

expect(eventSystem).not.toBe(patternSystem);
expect(eventSystem).toContain('single log or trace event');
expect(patternSystem).toContain('log/trace pattern');
});

it('returns 500 on AI provider error', async () => {
const { APICallError } = jest.requireMock('ai');
mockGenerateText.mockRejectedValueOnce(
new APICallError('Rate limited', 429),
);

const res = await request(app)
.post('/ai/summarize')
.send({ type: 'event', content: 'test' })
.expect(500);

expect(res.body.message).toContain('AI Provider Error');
});
});
81 changes: 81 additions & 0 deletions packages/api/src/routers/api/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,85 @@ ${JSON.stringify(allFieldsWithKeys.slice(0, 200).map(f => ({ field: f.key, type:
},
);

// ---------------------------------------------------------------------------
// POST /ai/summarize — generate a natural-language summary of a log, trace, or
// pattern using the configured LLM.
// ---------------------------------------------------------------------------

const TONE_VALUES = ['default', 'noir', 'attenborough', 'shakespeare'] as const;
type Tone = (typeof TONE_VALUES)[number];

const summarizeBodySchema = z.object({
type: z.enum(['event', 'pattern']),
content: z.string().min(1).max(50000),
tone: z.enum(TONE_VALUES).optional(),
});

// Hardcoded tone modifiers — never accept freeform style text from the client.
const TONE_SUFFIXES: Record<Exclude<Tone, 'default'>, string> = {
noir: 'Write in the style of a hard-boiled detective noir narrator.',
attenborough:
'Write in the style of Sir David Attenborough narrating a nature documentary.',
shakespeare: 'Write in the style of a Shakespearean dramatic monologue.',
};

router.post(
'/summarize',
validateRequest({ body: summarizeBodySchema }),
async (req, res, next) => {
try {
const model = getAIModel();
const { type, content, tone } = req.body;

const toneInstruction =
tone && tone !== 'default' ? `\n\n${TONE_SUFFIXES[tone]}` : '';

const formatInstruction = `

Format:
- Use **bold** for key details: service names, error types, status codes, durations.
- Use \`code\` for specific values: config keys, connection strings, env vars.
- Separate distinct points with line breaks.
- Keep total length under 4 sentences.`;

const systemPrompt =
type === 'pattern'
? `You are an expert observability engineer. The user will provide a log/trace pattern (a templatized message with occurrence count and sample events). Summarize it for an operator scanning a dashboard.

Rules:
- Lead with what matters: errors, failures, or elevated latency come first.
- If the pattern is healthy and routine, say so in ONE sentence and stop — do not invent concerns.
- If there is a real problem, explain what is wrong and one concrete next step (2-3 sentences max).
- Be terse and technical. Do not repeat the raw pattern — paraphrase.${formatInstruction}${toneInstruction}`
: `You are an expert observability engineer. The user will provide a single log or trace event (body, attributes, severity, timing, etc.). Summarize it for an operator scanning a dashboard.

Rules:
- Lead with what matters: errors, failures, or elevated latency come first.
- If the event is healthy and routine, say so in ONE sentence and stop — do not invent concerns.
- If there is a real problem, explain what is wrong and one concrete next step (2-3 sentences max).
- Be terse and technical. Do not repeat the raw event — paraphrase.${formatInstruction}${toneInstruction}`;

try {
const result = await generateText({
model,
system: systemPrompt,
experimental_telemetry: { isEnabled: true },
prompt: content,
});

return res.json({ summary: result.text });
} catch (err) {
if (err instanceof APICallError) {
throw new Api500Error(
`AI Provider Error. Status: ${err.statusCode}. Message: ${err.message}`,
);
}
throw err;
}
} catch (e) {
next(e);
}
},
);

export default router;
1 change: 1 addition & 0 deletions packages/app/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ module.exports = {
'^@/(.*)$': '<rootDir>/src/$1',
'^ky-universal$': '<rootDir>/src/__mocks__/ky-universal.ts',
'^ky$': '<rootDir>/src/__mocks__/ky-universal.ts',
'^react-markdown$': '<rootDir>/src/__mocks__/react-markdown.tsx',
},
setupFilesAfterEnv: ['<rootDir>/src/setupTests.tsx'],
};
10 changes: 10 additions & 0 deletions packages/app/src/__mocks__/react-markdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Jest mock for react-markdown (ESM-only module that Jest can't transform).
// Renders children as plain text — sufficient for unit tests.
export default function Markdown({
children,
}: {
children?: string;
components?: Record<string, unknown>;
}) {
return <div data-testid="markdown">{children}</div>;
}
Loading