frogo 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -0
- package/dist/agent/launch.js +384 -0
- package/dist/cli/commands/configure.js +243 -0
- package/dist/cli/commands/debug.js +9 -0
- package/dist/cli/commands/investigate.js +50 -0
- package/dist/cli/commands/mcp.js +53 -0
- package/dist/cli/commands/scan.js +5 -0
- package/dist/cli/index.js +22 -0
- package/dist/config/load.js +36 -0
- package/dist/config/save.js +19 -0
- package/dist/connectors/datadog.js +113 -0
- package/dist/connectors/demo-events.js +82 -0
- package/dist/connectors/local.js +25 -0
- package/dist/connectors/trigger.js +13 -0
- package/dist/connectors/vercel.js +13 -0
- package/dist/core/correlator.js +17 -0
- package/dist/core/investigator.js +49 -0
- package/dist/core/pattern-engine.js +108 -0
- package/dist/core/timeline.js +24 -0
- package/dist/core/types.js +1 -0
- package/dist/llm/explain.js +14 -0
- package/package.json +37 -0
- package/src/agent/launch.ts +449 -0
- package/src/cli/commands/configure.ts +265 -0
- package/src/cli/commands/debug.ts +10 -0
- package/src/cli/commands/mcp.ts +66 -0
- package/src/cli/commands/scan.ts +6 -0
- package/src/cli/index.ts +27 -0
- package/src/config/load.ts +42 -0
- package/src/config/save.ts +27 -0
- package/src/connectors/datadog.ts +152 -0
- package/src/connectors/local.ts +27 -0
- package/src/connectors/trigger.ts +16 -0
- package/src/connectors/vercel.ts +16 -0
- package/src/core/correlator.ts +27 -0
- package/src/core/investigator.ts +64 -0
- package/src/core/pattern-engine.ts +139 -0
- package/src/core/timeline.ts +32 -0
- package/src/core/types.ts +92 -0
- package/src/llm/explain.ts +20 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import prompts, { PromptObject } from "prompts";
|
|
3
|
+
import { loadConfig } from "../../config/load.js";
|
|
4
|
+
import { saveConfig } from "../../config/save.js";
|
|
5
|
+
import type { FrogConfig, LangSmithConfig } from "../../core/types.js";
|
|
6
|
+
|
|
7
|
+
const menuChoices = [
|
|
8
|
+
{ title: "Vercel", value: "vercel" },
|
|
9
|
+
{ title: "Trigger.dev", value: "trigger" },
|
|
10
|
+
{ title: "Datadog", value: "datadog" },
|
|
11
|
+
{ title: "LangSmith", value: "langsmith" },
|
|
12
|
+
{ title: "LLM provider", value: "llmProvider" },
|
|
13
|
+
{ title: "Show configuration", value: "show" },
|
|
14
|
+
{ title: "Done", value: "done" }
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
const promptCredentials = async <T extends Record<string, string | undefined>>(
|
|
18
|
+
questions: PromptObject<string>[]
|
|
19
|
+
): Promise<T> => {
|
|
20
|
+
const answers = (await prompts(questions)) as T;
|
|
21
|
+
return answers;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const vqlQuestions: PromptObject<string>[] = [
|
|
25
|
+
{
|
|
26
|
+
type: "text",
|
|
27
|
+
name: "vercelToken",
|
|
28
|
+
message: "Vercel token",
|
|
29
|
+
initial: ""
|
|
30
|
+
}
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const triggerQuestions: PromptObject<string>[] = [
|
|
34
|
+
{
|
|
35
|
+
type: "text",
|
|
36
|
+
name: "triggerToken",
|
|
37
|
+
message: "Trigger.dev API key",
|
|
38
|
+
initial: ""
|
|
39
|
+
}
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const datadogQuestions: PromptObject<string>[] = [
|
|
43
|
+
{
|
|
44
|
+
type: "text",
|
|
45
|
+
name: "apiKey",
|
|
46
|
+
message: "Datadog API key for MCP",
|
|
47
|
+
initial: ""
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: "text",
|
|
51
|
+
name: "appKey",
|
|
52
|
+
message: "Datadog Application key",
|
|
53
|
+
initial: ""
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
type: "text",
|
|
57
|
+
name: "site",
|
|
58
|
+
message: "Datadog site",
|
|
59
|
+
initial: "datadoghq.com"
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
type: "text",
|
|
63
|
+
name: "logsSite",
|
|
64
|
+
message: "Datadog logs site (optional)",
|
|
65
|
+
initial: ""
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
type: "text",
|
|
69
|
+
name: "metricsSite",
|
|
70
|
+
message: "Datadog metrics site (optional)",
|
|
71
|
+
initial: ""
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
type: "text",
|
|
75
|
+
name: "command",
|
|
76
|
+
message: "Datadog MCP command",
|
|
77
|
+
initial: "datadog-mcp-server"
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
name: "args",
|
|
82
|
+
message: "Additional args for Datadog command",
|
|
83
|
+
initial: ""
|
|
84
|
+
}
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
const langsmithQuestions: PromptObject<string>[] = [
|
|
88
|
+
{
|
|
89
|
+
type: "text",
|
|
90
|
+
name: "apiKey",
|
|
91
|
+
message: "LangSmith API key",
|
|
92
|
+
initial: ""
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
type: "text",
|
|
96
|
+
name: "workspaceKey",
|
|
97
|
+
message: "LangSmith workspace ID (optional)",
|
|
98
|
+
initial: ""
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
type: "text",
|
|
102
|
+
name: "mcpUrl",
|
|
103
|
+
message: "LangSmith MCP URL",
|
|
104
|
+
initial: "https://langsmith-mcp-server.onrender.com/mcp"
|
|
105
|
+
}
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
const llmProviderChoices = [
|
|
109
|
+
{ title: "openai", value: "openai" },
|
|
110
|
+
{ title: "anthropic", value: "anthropic" },
|
|
111
|
+
{ title: "custom", value: "custom" }
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
const llmProviderQuestions: PromptObject<string>[] = [
|
|
115
|
+
{
|
|
116
|
+
type: "select",
|
|
117
|
+
name: "provider",
|
|
118
|
+
message: "LLM provider",
|
|
119
|
+
choices: llmProviderChoices,
|
|
120
|
+
initial: 0
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
type: "text",
|
|
124
|
+
name: "endpoint",
|
|
125
|
+
message: "Provider endpoint or MCP URL (optional)",
|
|
126
|
+
initial: "https://api.openai.com/v1"
|
|
127
|
+
},
|
|
128
|
+
{
|
|
129
|
+
type: "text",
|
|
130
|
+
name: "model",
|
|
131
|
+
message: "Default model",
|
|
132
|
+
initial: "gpt-4o-mini"
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
type: "text",
|
|
136
|
+
name: "systemPrompt",
|
|
137
|
+
message: "System prompt (optional)",
|
|
138
|
+
initial: "You are Frogo, an incident investigator. Answer concisely."
|
|
139
|
+
}
|
|
140
|
+
];
|
|
141
|
+
|
|
142
|
+
function trimOrUndefined(value?: string): string | undefined {
|
|
143
|
+
if (!value) return undefined;
|
|
144
|
+
const trimmed = value.trim();
|
|
145
|
+
return trimmed === "" ? undefined : trimmed;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function configureDatadog(config: FrogConfig): Promise<FrogConfig> {
|
|
149
|
+
const answers = await promptCredentials<Record<string, string>>(datadogQuestions);
|
|
150
|
+
return {
|
|
151
|
+
...config,
|
|
152
|
+
datadog: {
|
|
153
|
+
apiKey: trimOrUndefined(answers.apiKey),
|
|
154
|
+
appKey: trimOrUndefined(answers.appKey),
|
|
155
|
+
site: trimOrUndefined(answers.site),
|
|
156
|
+
logsSite: trimOrUndefined(answers.logsSite),
|
|
157
|
+
metricsSite: trimOrUndefined(answers.metricsSite),
|
|
158
|
+
command: trimOrUndefined(answers.command),
|
|
159
|
+
args: answers.args ? answers.args.split(" ").filter(Boolean) : undefined
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function configureLangSmith(config: FrogConfig): Promise<FrogConfig> {
|
|
165
|
+
const answers = await promptCredentials<Record<string, string>>(langsmithQuestions);
|
|
166
|
+
return {
|
|
167
|
+
...config,
|
|
168
|
+
langsmith: {
|
|
169
|
+
apiKey: trimOrUndefined(answers.apiKey),
|
|
170
|
+
workspaceKey: trimOrUndefined(answers.workspaceKey),
|
|
171
|
+
mcpUrl: trimOrUndefined(answers.mcpUrl)
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function configureLLMProvider(config: FrogConfig): Promise<FrogConfig> {
|
|
177
|
+
const answers = await promptCredentials<Record<string, string>>(llmProviderQuestions);
|
|
178
|
+
return {
|
|
179
|
+
...config,
|
|
180
|
+
llmProvider: {
|
|
181
|
+
provider: trimOrUndefined(answers.provider),
|
|
182
|
+
endpoint: trimOrUndefined(answers.endpoint),
|
|
183
|
+
model: trimOrUndefined(answers.model),
|
|
184
|
+
systemPrompt: trimOrUndefined(answers.systemPrompt)
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async function showConfig(config: FrogConfig) {
|
|
190
|
+
console.log("Current configuration:");
|
|
191
|
+
console.log(JSON.stringify(config, null, 2));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function configure(cmd: Command) {
|
|
195
|
+
let config = await loadConfig();
|
|
196
|
+
console.log("🐸 Frog configure — pick an integration to update");
|
|
197
|
+
|
|
198
|
+
while (true) {
|
|
199
|
+
const { choice } = (await promptCredentials<Record<string, string>>([
|
|
200
|
+
{
|
|
201
|
+
type: "select",
|
|
202
|
+
name: "choice",
|
|
203
|
+
message: "What would you like to configure?",
|
|
204
|
+
choices: menuChoices
|
|
205
|
+
}
|
|
206
|
+
])) as { choice?: string };
|
|
207
|
+
|
|
208
|
+
if (!choice) {
|
|
209
|
+
console.log("Configuration aborted.");
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (choice === "done") {
|
|
214
|
+
await saveConfig(config);
|
|
215
|
+
console.log("🐸 Configuration saved.");
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (choice === "show") {
|
|
220
|
+
await showConfig(config);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
switch (choice) {
|
|
225
|
+
case "vercel":
|
|
226
|
+
config = {
|
|
227
|
+
...config,
|
|
228
|
+
vercelToken: trimOrUndefined((await promptCredentials<Record<string, string>>(vqlQuestions)).vercelToken)
|
|
229
|
+
};
|
|
230
|
+
await saveConfig(config);
|
|
231
|
+
console.log("✔ Vercel token updated.");
|
|
232
|
+
break;
|
|
233
|
+
case "trigger":
|
|
234
|
+
config = {
|
|
235
|
+
...config,
|
|
236
|
+
triggerToken: trimOrUndefined((await promptCredentials<Record<string, string>>(triggerQuestions)).triggerToken)
|
|
237
|
+
};
|
|
238
|
+
await saveConfig(config);
|
|
239
|
+
console.log("✔ Trigger.dev API key updated.");
|
|
240
|
+
break;
|
|
241
|
+
case "datadog":
|
|
242
|
+
config = await configureDatadog(config);
|
|
243
|
+
await saveConfig(config);
|
|
244
|
+
console.log("✔ Datadog MCP config updated.");
|
|
245
|
+
break;
|
|
246
|
+
case "langsmith":
|
|
247
|
+
config = await configureLangSmith(config);
|
|
248
|
+
await saveConfig(config);
|
|
249
|
+
console.log("✔ LangSmith MCP config updated.");
|
|
250
|
+
break;
|
|
251
|
+
case "llmProvider":
|
|
252
|
+
config = await configureLLMProvider(config);
|
|
253
|
+
await saveConfig(config);
|
|
254
|
+
console.log("✔ LLM provider updated.");
|
|
255
|
+
console.log("Set `FROGO_AI_API_KEY` (or place it in your `.env`) instead of saving the key to disk.");
|
|
256
|
+
break;
|
|
257
|
+
default:
|
|
258
|
+
console.log(`Unknown option: ${choice}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const configureCommand = new Command("configure")
|
|
264
|
+
.description("connect integrations and save config")
|
|
265
|
+
.action(async () => configure(new Command()));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { runInvestigation } from "../../core/investigator.js";
|
|
3
|
+
|
|
4
|
+
export const debugCommand = new Command("debug")
|
|
5
|
+
.description("run deterministic investigation against a focused query")
|
|
6
|
+
.argument("query", "natural language prompt")
|
|
7
|
+
.action(async (query: string) => {
|
|
8
|
+
console.log(`🐸 Debugging query: ${query}`);
|
|
9
|
+
await runInvestigation({ windowMinutes: 15, query });
|
|
10
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import prompts, { PromptObject } from "prompts";
|
|
3
|
+
import { loadConfig } from "../../config/load.js";
|
|
4
|
+
import { saveConfig } from "../../config/save.js";
|
|
5
|
+
import type { FrogConfig } from "../../core/types.js";
|
|
6
|
+
|
|
7
|
+
const LANGSMITH_SERVER = "langsmith";
|
|
8
|
+
|
|
9
|
+
const langsmithQuestions: PromptObject<string>[] = [
|
|
10
|
+
{
|
|
11
|
+
type: "text",
|
|
12
|
+
name: "apiKey",
|
|
13
|
+
message: "LangSmith API key (ls_api_key_... or lsv2_pt_...)",
|
|
14
|
+
validate: (value: string) => (value.trim() ? true : "API key is required" )
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
type: "text",
|
|
18
|
+
name: "workspaceKey",
|
|
19
|
+
message: "LangSmith workspace ID (optional)",
|
|
20
|
+
initial: ""
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
type: "text",
|
|
24
|
+
name: "mcpUrl",
|
|
25
|
+
message: "LangSmith MCP URL",
|
|
26
|
+
initial: "https://langsmith-mcp-server.onrender.com/mcp"
|
|
27
|
+
}
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
export const mcpCommand = new Command("mcp").description("manage MCP server integrations");
|
|
31
|
+
|
|
32
|
+
mcpCommand
|
|
33
|
+
.command("login")
|
|
34
|
+
.description("store credentials for an MCP server, e.g. langsmith")
|
|
35
|
+
.argument("server", "name of the MCP server")
|
|
36
|
+
.action(async (server: string) => {
|
|
37
|
+
if (server.toLowerCase() !== LANGSMITH_SERVER) {
|
|
38
|
+
console.log(`Unsupported MCP server: ${server}. Try 'frogo mcp login langsmith'.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const answers = (await prompts(langsmithQuestions)) as {
|
|
43
|
+
apiKey?: string;
|
|
44
|
+
workspaceKey?: string;
|
|
45
|
+
mcpUrl?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (!answers.apiKey) {
|
|
49
|
+
console.log("LangSmith login canceled.");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const config = await loadConfig();
|
|
54
|
+
const updated: FrogConfig = {
|
|
55
|
+
...config,
|
|
56
|
+
langsmith: {
|
|
57
|
+
apiKey: answers.apiKey.trim(),
|
|
58
|
+
workspaceKey: answers.workspaceKey?.trim() || undefined,
|
|
59
|
+
mcpUrl: answers.mcpUrl?.trim() || undefined
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
await saveConfig(updated);
|
|
64
|
+
console.log("🐸 LangSmith MCP credentials saved.");
|
|
65
|
+
console.log("If you plan to use ai-sdk's mcp login CLI, run `npx ai-sdk mcp login langsmith` now.");
|
|
66
|
+
});
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { configureCommand } from "./commands/configure.js";
|
|
4
|
+
import { debugCommand } from "./commands/debug.js";
|
|
5
|
+
import { scanCommand } from "./commands/scan.js";
|
|
6
|
+
import { runAgentChat } from "../agent/launch.js";
|
|
7
|
+
import { mcpCommand } from "./commands/mcp.js";
|
|
8
|
+
|
|
9
|
+
const program = new Command();
|
|
10
|
+
|
|
11
|
+
program.name("frogo");
|
|
12
|
+
program.description("Frogo v0 — incident investigator CLI");
|
|
13
|
+
program.version("0.1.0");
|
|
14
|
+
|
|
15
|
+
program.addCommand(configureCommand);
|
|
16
|
+
program.addCommand(scanCommand);
|
|
17
|
+
program.addCommand(debugCommand);
|
|
18
|
+
program.addCommand(mcpCommand);
|
|
19
|
+
|
|
20
|
+
program.action(() => {
|
|
21
|
+
runAgentChat().catch((error) => {
|
|
22
|
+
console.error("Agent process failed:", error);
|
|
23
|
+
process.exit(1);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
program.parse();
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { FrogConfig } from "../core/types.js";
|
|
5
|
+
|
|
6
|
+
const PROJECT_CONFIG = ".frogo.json";
|
|
7
|
+
const LEGACY_PROJECT_CONFIG = ".frog.json";
|
|
8
|
+
const GLOBAL_DIR = path.join(os.homedir(), ".frogo");
|
|
9
|
+
const LEGACY_GLOBAL_DIR = path.join(os.homedir(), ".frog");
|
|
10
|
+
const GLOBAL_CONFIG = path.join(GLOBAL_DIR, "config.json");
|
|
11
|
+
const LEGACY_GLOBAL_CONFIG = path.join(LEGACY_GLOBAL_DIR, "config.json");
|
|
12
|
+
|
|
13
|
+
async function readJson(filePath: string): Promise<FrogConfig | null> {
|
|
14
|
+
try {
|
|
15
|
+
const raw = await fs.readFile(filePath, "utf-8");
|
|
16
|
+
const parsed: FrogConfig = JSON.parse(raw);
|
|
17
|
+
return parsed;
|
|
18
|
+
} catch (error) {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function loadConfig(): Promise<FrogConfig> {
|
|
24
|
+
const localPaths = [path.join(process.cwd(), PROJECT_CONFIG), path.join(process.cwd(), LEGACY_PROJECT_CONFIG)];
|
|
25
|
+
|
|
26
|
+
for (const localPath of localPaths) {
|
|
27
|
+
const local = await readJson(localPath);
|
|
28
|
+
if (local) {
|
|
29
|
+
return local;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const globalPaths = [GLOBAL_CONFIG, LEGACY_GLOBAL_CONFIG];
|
|
34
|
+
for (const globalPath of globalPaths) {
|
|
35
|
+
const global = await readJson(globalPath);
|
|
36
|
+
if (global) {
|
|
37
|
+
return global;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { FrogConfig } from "../core/types.js";
|
|
5
|
+
|
|
6
|
+
const PROJECT_CONFIG = ".frogo.json";
|
|
7
|
+
const GLOBAL_DIR = path.join(os.homedir(), ".frogo");
|
|
8
|
+
const GLOBAL_CONFIG = path.join(GLOBAL_DIR, "config.json");
|
|
9
|
+
|
|
10
|
+
export async function saveConfig(
|
|
11
|
+
config: FrogConfig,
|
|
12
|
+
options?: { local?: boolean; global?: boolean }
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
const local = options?.local ?? true;
|
|
15
|
+
const global = options?.global ?? true;
|
|
16
|
+
const serialized = JSON.stringify(config, null, 2);
|
|
17
|
+
|
|
18
|
+
if (local) {
|
|
19
|
+
const localPath = path.join(process.cwd(), PROJECT_CONFIG);
|
|
20
|
+
await fs.writeFile(localPath, serialized, "utf-8");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
if (global) {
|
|
24
|
+
await fs.mkdir(GLOBAL_DIR, { recursive: true });
|
|
25
|
+
await fs.writeFile(GLOBAL_CONFIG, serialized, "utf-8");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Client } from "@modelcontextprotocol/sdk/client";
|
|
2
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
3
|
+
import { Connector, FrogConfig, NormalizedEvent } from "../core/types.js";
|
|
4
|
+
|
|
5
|
+
const DEFAULT_COMMAND = "datadog-mcp-server";
|
|
6
|
+
const DEFAULT_QUERY = "status:error OR status:warn";
|
|
7
|
+
const DEFAULT_LIMIT = 80;
|
|
8
|
+
|
|
9
|
+
interface RawDatadogLogEntry {
|
|
10
|
+
id: string;
|
|
11
|
+
attributes?: {
|
|
12
|
+
timestamp?: string;
|
|
13
|
+
message?: string;
|
|
14
|
+
status?: string;
|
|
15
|
+
service?: string;
|
|
16
|
+
host?: string;
|
|
17
|
+
logger?: string;
|
|
18
|
+
tags?: string[];
|
|
19
|
+
[key: string]: unknown;
|
|
20
|
+
};
|
|
21
|
+
relationships?: {
|
|
22
|
+
trace?: {
|
|
23
|
+
data?: {
|
|
24
|
+
id: string;
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RawDatadogSearchLogsResponse {
|
|
31
|
+
data?: RawDatadogLogEntry[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export class DatadogConnector implements Connector {
|
|
35
|
+
name = "datadog";
|
|
36
|
+
|
|
37
|
+
constructor(private config: FrogConfig) {}
|
|
38
|
+
|
|
39
|
+
async fetchEvents(since: Date): Promise<NormalizedEvent[]> {
|
|
40
|
+
const datadog = this.config.datadog;
|
|
41
|
+
if (!datadog?.apiKey || !datadog?.appKey) {
|
|
42
|
+
return [];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const command = datadog.command ?? DEFAULT_COMMAND;
|
|
46
|
+
const args = datadog.args ?? [];
|
|
47
|
+
const env: Record<string, string> = { ...process.env } as Record<string, string>;
|
|
48
|
+
env.DD_API_KEY = datadog.apiKey;
|
|
49
|
+
env.DD_APP_KEY = datadog.appKey;
|
|
50
|
+
env.DD_SITE = datadog.site ?? env.DD_SITE ?? "datadoghq.com";
|
|
51
|
+
if (datadog.logsSite) {
|
|
52
|
+
env.DD_LOGS_SITE = datadog.logsSite;
|
|
53
|
+
}
|
|
54
|
+
if (datadog.metricsSite) {
|
|
55
|
+
env.DD_METRICS_SITE = datadog.metricsSite;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const transport = new StdioClientTransport({
|
|
59
|
+
command,
|
|
60
|
+
args,
|
|
61
|
+
env,
|
|
62
|
+
stderr: "inherit"
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const client = new Client({ name: "frog-datadog-connector", version: "0.1.0" });
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
await client.connect(transport);
|
|
69
|
+
|
|
70
|
+
const response = await client.callTool({
|
|
71
|
+
name: "search-logs",
|
|
72
|
+
arguments: {
|
|
73
|
+
filter: {
|
|
74
|
+
query: datadog.query ?? DEFAULT_QUERY,
|
|
75
|
+
from: since.toISOString(),
|
|
76
|
+
to: new Date().toISOString(),
|
|
77
|
+
indexes: datadog.indexes
|
|
78
|
+
},
|
|
79
|
+
limit: datadog.limit ?? DEFAULT_LIMIT
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const toolResponse = response as { content?: Array<{ type: string; text?: string }> };
|
|
84
|
+
const raw = toolResponse.content?.find((item) => item.type === "text")?.text;
|
|
85
|
+
if (!raw) {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const parsed = this.safeParse(raw);
|
|
90
|
+
if (!parsed) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (parsed.data ?? [])
|
|
95
|
+
.map((entry) => this.normalizeEntry(entry))
|
|
96
|
+
.filter((event) => event.timestamp.getTime() >= since.getTime());
|
|
97
|
+
} catch (error) {
|
|
98
|
+
console.error("Datadog MCP fetch failed:", error);
|
|
99
|
+
return [];
|
|
100
|
+
} finally {
|
|
101
|
+
await transport.close();
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private safeParse(raw: string): RawDatadogSearchLogsResponse | null {
|
|
106
|
+
try {
|
|
107
|
+
return JSON.parse(raw) as RawDatadogSearchLogsResponse;
|
|
108
|
+
} catch (error) {
|
|
109
|
+
console.error("Failed to parse Datadog search response", error);
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private normalizeEntry(entry: RawDatadogLogEntry): NormalizedEvent {
|
|
115
|
+
const attributes = entry.attributes ?? {};
|
|
116
|
+
const timestamp = attributes.timestamp ? new Date(attributes.timestamp) : new Date();
|
|
117
|
+
return {
|
|
118
|
+
source: "datadog",
|
|
119
|
+
type: attributes.service ? `${attributes.service}.log` : "datadog.log",
|
|
120
|
+
severity: mapSeverity(attributes.status),
|
|
121
|
+
timestamp,
|
|
122
|
+
metadata: {
|
|
123
|
+
id: entry.id,
|
|
124
|
+
message: attributes.message,
|
|
125
|
+
status: attributes.status,
|
|
126
|
+
service: attributes.service,
|
|
127
|
+
host: attributes.host,
|
|
128
|
+
logger: attributes.logger,
|
|
129
|
+
traceId: entry.relationships?.trace?.data?.id,
|
|
130
|
+
tags: attributes.tags,
|
|
131
|
+
raw: entry
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function mapSeverity(status?: string): "info" | "warn" | "error" {
|
|
138
|
+
if (!status) {
|
|
139
|
+
return "info";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const normalized = status.toLowerCase();
|
|
143
|
+
if (normalized === "error" || normalized === "critical" || normalized === "fatal") {
|
|
144
|
+
return "error";
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (normalized === "warn" || normalized === "warning") {
|
|
148
|
+
return "warn";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return "info";
|
|
152
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Connector, FrogConfig, NormalizedEvent } from "../core/types.js";
|
|
4
|
+
|
|
5
|
+
export class LocalConnector implements Connector {
|
|
6
|
+
name = "local";
|
|
7
|
+
|
|
8
|
+
constructor(private config: FrogConfig) {}
|
|
9
|
+
|
|
10
|
+
async fetchEvents(since: Date): Promise<NormalizedEvent[]> {
|
|
11
|
+
const manualPath = process.env.FROGO_LOCAL_EVENTS;
|
|
12
|
+
if (!manualPath) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
const resolved = path.resolve(process.cwd(), manualPath);
|
|
18
|
+
const raw = await fs.readFile(resolved, "utf-8");
|
|
19
|
+
const parsed = JSON.parse(raw) as Array<NormalizedEvent & { timestamp: string }>;
|
|
20
|
+
return parsed
|
|
21
|
+
.map((event) => ({ ...event, timestamp: new Date(event.timestamp) }))
|
|
22
|
+
.filter((event) => event.timestamp.getTime() >= since.getTime());
|
|
23
|
+
} catch (error) {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Connector, NormalizedEvent, FrogConfig } from "../core/types.js";
|
|
2
|
+
|
|
3
|
+
export class TriggerConnector implements Connector {
|
|
4
|
+
name = "trigger";
|
|
5
|
+
|
|
6
|
+
constructor(private config: FrogConfig) {}
|
|
7
|
+
|
|
8
|
+
async fetchEvents(since: Date): Promise<NormalizedEvent[]> {
|
|
9
|
+
if (!this.config.triggerToken) {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// TODO: implement Trigger.dev API fetch once credentials are available.
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { Connector, NormalizedEvent, FrogConfig } from "../core/types.js";
|
|
2
|
+
|
|
3
|
+
export class VercelConnector implements Connector {
|
|
4
|
+
name = "vercel";
|
|
5
|
+
|
|
6
|
+
constructor(private config: FrogConfig) {}
|
|
7
|
+
|
|
8
|
+
async fetchEvents(since: Date): Promise<NormalizedEvent[]> {
|
|
9
|
+
if (!this.config.vercelToken) {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
// TODO: hook into the Vercel API once the token and project are configured.
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { IncidentCluster, NormalizedEvent, PatternMatch } from "./types.js";
|
|
2
|
+
import { buildIncidentClusters } from "./timeline.js";
|
|
3
|
+
import { matchPattern } from "./pattern-engine.js";
|
|
4
|
+
|
|
5
|
+
export interface IncidentReport {
|
|
6
|
+
cluster: IncidentCluster;
|
|
7
|
+
match: PatternMatch;
|
|
8
|
+
timeline: NormalizedEvent[];
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function correlateEvents(events: NormalizedEvent[]): IncidentReport | null {
|
|
12
|
+
const sortedTimeline = [...events].sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
|
13
|
+
const clusters = buildIncidentClusters(events);
|
|
14
|
+
|
|
15
|
+
for (const cluster of clusters) {
|
|
16
|
+
const match = matchPattern(cluster);
|
|
17
|
+
if (match) {
|
|
18
|
+
return {
|
|
19
|
+
cluster,
|
|
20
|
+
match,
|
|
21
|
+
timeline: sortedTimeline
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return null;
|
|
27
|
+
}
|