@pranshulsoni/flowwatch 1.0.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 +442 -0
- package/dist/ai/groqInsightService.d.ts +39 -0
- package/dist/ai/groqInsightService.js +230 -0
- package/dist/createFlowwatch.d.ts +17 -0
- package/dist/createFlowwatch.js +90 -0
- package/dist/dashboard/routes/dashboardResponse.d.ts +204 -0
- package/dist/dashboard/routes/dashboardResponse.js +248 -0
- package/dist/dashboard/routes/router.d.ts +13 -0
- package/dist/dashboard/routes/router.js +708 -0
- package/dist/dashboard/static/dashboard.html +6061 -0
- package/dist/engine/background/queues/workflowQueue.d.ts +6 -0
- package/dist/engine/background/queues/workflowQueue.js +14 -0
- package/dist/engine/background/workers/workflowWorker.d.ts +15 -0
- package/dist/engine/background/workers/workflowWorker.js +98 -0
- package/dist/engine/errors/errorEngine.d.ts +27 -0
- package/dist/engine/errors/errorEngine.js +115 -0
- package/dist/engine/flags/evaluateFlag.d.ts +3 -0
- package/dist/engine/flags/evaluateFlag.js +50 -0
- package/dist/engine/flags/flagEngine.d.ts +9 -0
- package/dist/engine/flags/flagEngine.js +52 -0
- package/dist/engine/flags/hashRollout.d.ts +1 -0
- package/dist/engine/flags/hashRollout.js +9 -0
- package/dist/engine/flags/types.d.ts +7 -0
- package/dist/engine/flags/types.js +1 -0
- package/dist/engine/trace/traceEngine.d.ts +26 -0
- package/dist/engine/trace/traceEngine.js +76 -0
- package/dist/engine/workflows/types.d.ts +28 -0
- package/dist/engine/workflows/types.js +1 -0
- package/dist/engine/workflows/workflowEngine.d.ts +15 -0
- package/dist/engine/workflows/workflowEngine.js +112 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +3 -0
- package/dist/persistence/cache/redisClient.d.ts +2 -0
- package/dist/persistence/cache/redisClient.js +4 -0
- package/dist/persistence/db/postgres.d.ts +3 -0
- package/dist/persistence/db/postgres.js +4 -0
- package/dist/persistence/migrations/migrationRunner.d.ts +3 -0
- package/dist/persistence/migrations/migrationRunner.js +46 -0
- package/dist/persistence/migrations/migrations.d.ts +5 -0
- package/dist/persistence/migrations/migrations.js +191 -0
- package/dist/persistence/repositories/errors/errorRepository.d.ts +38 -0
- package/dist/persistence/repositories/errors/errorRepository.js +63 -0
- package/dist/persistence/repositories/flags/flagRepository.d.ts +72 -0
- package/dist/persistence/repositories/flags/flagRepository.js +245 -0
- package/dist/persistence/repositories/traces/traceRepository.d.ts +64 -0
- package/dist/persistence/repositories/traces/traceRepository.js +110 -0
- package/dist/persistence/repositories/workflows/workflowRepository.d.ts +93 -0
- package/dist/persistence/repositories/workflows/workflowRepository.js +260 -0
- package/dist/persistence/transaction.d.ts +2 -0
- package/dist/persistence/transaction.js +16 -0
- package/dist/runtime/config/normalizeConfig.d.ts +2 -0
- package/dist/runtime/config/normalizeConfig.js +46 -0
- package/dist/runtime/config/validationConfig.d.ts +2 -0
- package/dist/runtime/config/validationConfig.js +119 -0
- package/dist/runtime/health/healthService.d.ts +30 -0
- package/dist/runtime/health/healthService.js +54 -0
- package/dist/runtime/tracing/traceContext.d.ts +12 -0
- package/dist/runtime/tracing/traceContext.js +28 -0
- package/dist/runtime/tracing/tracingMiddleware.d.ts +3 -0
- package/dist/runtime/tracing/tracingMiddleware.js +46 -0
- package/dist/search/elasticsearch/client.d.ts +2 -0
- package/dist/search/elasticsearch/client.js +4 -0
- package/dist/search/elasticsearch/indexSetup.d.ts +3 -0
- package/dist/search/elasticsearch/indexSetup.js +43 -0
- package/dist/search/elasticsearch/indexer.d.ts +9 -0
- package/dist/search/elasticsearch/indexer.js +86 -0
- package/dist/search/elasticsearch/mappingChecker.d.ts +2 -0
- package/dist/search/elasticsearch/mappingChecker.js +28 -0
- package/dist/types/index.d.ts +48 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/flowwatchEnvStore.d.ts +27 -0
- package/dist/utils/flowwatchEnvStore.js +145 -0
- package/package.json +63 -0
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { Queue } from "bullmq";
|
|
2
|
+
export interface WorkflowJobData {
|
|
3
|
+
executionId: string;
|
|
4
|
+
}
|
|
5
|
+
export declare function createWorkflowQueue(redisUrl: string): Queue<WorkflowJobData, any, string, WorkflowJobData, any, string>;
|
|
6
|
+
export declare function addWorkflowJobToQueue(queue: ReturnType<typeof createWorkflowQueue>, executionId: string): Promise<void>;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Queue } from "bullmq";
|
|
2
|
+
export function createWorkflowQueue(redisUrl) {
|
|
3
|
+
return new Queue("workflows", {
|
|
4
|
+
prefix: "{flowwatch}",
|
|
5
|
+
connection: {
|
|
6
|
+
url: redisUrl,
|
|
7
|
+
},
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export async function addWorkflowJobToQueue(queue, executionId) {
|
|
11
|
+
await queue.add("run-workflow", {
|
|
12
|
+
executionId,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Worker } from "bullmq";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import type { WorkflowJobData } from "../queues/workflowQueue.js";
|
|
4
|
+
import type { RegisteredWorkflow } from "../../workflows/types.js";
|
|
5
|
+
import type { TraceEngine } from "../../trace/traceEngine.js";
|
|
6
|
+
import type { CaptureErrorFunction } from "../../errors/errorEngine.js";
|
|
7
|
+
export interface WorkflowWorkerOptions {
|
|
8
|
+
redisUrl: string;
|
|
9
|
+
pool: Pool;
|
|
10
|
+
getWorkflow: (name: string) => RegisteredWorkflow | undefined;
|
|
11
|
+
traceEngine: TraceEngine;
|
|
12
|
+
captureError: CaptureErrorFunction;
|
|
13
|
+
}
|
|
14
|
+
export declare function createWorkflowWorker(options: WorkflowWorkerOptions): Worker<WorkflowJobData>;
|
|
15
|
+
export declare function executeWorkflow(executionId: string, worker: WorkflowWorkerOptions): Promise<void>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { Worker } from "bullmq";
|
|
2
|
+
import { getWorkflowExecution, getWorkflowExecutionSteps, markWorkflowExecutionCompleted, markWorkflowExecutionFailed, markWorkflowExecutionRunning, markWorkflowStepCompleted, markWorkflowStepFailed, markWorkflowStepRunning, } from "../../../persistence/repositories/workflows/workflowRepository.js";
|
|
3
|
+
export function createWorkflowWorker(options) {
|
|
4
|
+
return new Worker("workflows", async (job) => {
|
|
5
|
+
await executeWorkflow(job.data.executionId, options);
|
|
6
|
+
}, {
|
|
7
|
+
prefix: "{flowwatch}",
|
|
8
|
+
connection: {
|
|
9
|
+
url: options.redisUrl,
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
}
|
|
13
|
+
export async function executeWorkflow(executionId, worker) {
|
|
14
|
+
const result = await getWorkflowExecution(worker.pool, executionId);
|
|
15
|
+
if (!result) {
|
|
16
|
+
throw new Error("No workflow exists with that id");
|
|
17
|
+
}
|
|
18
|
+
const executions = await getWorkflowExecutionSteps(worker.pool, executionId);
|
|
19
|
+
if (executions.length === 0) {
|
|
20
|
+
throw new Error("No workflow execution steps exists");
|
|
21
|
+
}
|
|
22
|
+
const workflowToExecute = worker.getWorkflow(result.workflow_name);
|
|
23
|
+
if (!workflowToExecute) {
|
|
24
|
+
throw new Error("Cannot find workflow in the working memory");
|
|
25
|
+
}
|
|
26
|
+
await markWorkflowExecutionRunning(worker.pool, executionId);
|
|
27
|
+
try {
|
|
28
|
+
let lastStepOutput;
|
|
29
|
+
for (const stepExecution of executions) {
|
|
30
|
+
if (stepExecution.status === "completed") {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const registeredStep = workflowToExecute.steps.find((step) => {
|
|
34
|
+
return step.name === stepExecution.step_name;
|
|
35
|
+
});
|
|
36
|
+
if (!registeredStep) {
|
|
37
|
+
throw new Error(`Cannot find registered step: ${stepExecution.step_name}`);
|
|
38
|
+
}
|
|
39
|
+
let attempt = stepExecution.attempt_count;
|
|
40
|
+
const maxAttempts = stepExecution.max_retries + 1;
|
|
41
|
+
while (attempt < maxAttempts) {
|
|
42
|
+
await markWorkflowStepRunning(worker.pool, stepExecution.id);
|
|
43
|
+
try {
|
|
44
|
+
lastStepOutput = await worker.traceEngine.trace(`flowwatch.workflow.step.${stepExecution.step_name}`, "workflow_step", async () => registeredStep.run(stepExecution.input), {
|
|
45
|
+
workflowName: result.workflow_name,
|
|
46
|
+
workflowVersion: result.workflow_version,
|
|
47
|
+
executionId,
|
|
48
|
+
stepExecutionId: stepExecution.id,
|
|
49
|
+
stepName: stepExecution.step_name,
|
|
50
|
+
stepIndex: stepExecution.step_index,
|
|
51
|
+
attempt: attempt + 1,
|
|
52
|
+
});
|
|
53
|
+
await markWorkflowStepCompleted(worker.pool, stepExecution.id, lastStepOutput);
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
attempt += 1;
|
|
58
|
+
await worker.captureError(error, {
|
|
59
|
+
source: "workflow",
|
|
60
|
+
category: "server",
|
|
61
|
+
level: "error",
|
|
62
|
+
statusCode: 500,
|
|
63
|
+
metadata: {
|
|
64
|
+
workflowName: result.workflow_name,
|
|
65
|
+
workflowVersion: result.workflow_version,
|
|
66
|
+
executionId,
|
|
67
|
+
stepExecutionId: stepExecution.id,
|
|
68
|
+
stepName: stepExecution.step_name,
|
|
69
|
+
stepIndex: stepExecution.step_index,
|
|
70
|
+
attempt,
|
|
71
|
+
maxAttempts,
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
await markWorkflowStepFailed(worker.pool, stepExecution.id, error);
|
|
75
|
+
if (attempt >= maxAttempts) {
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
await markWorkflowExecutionCompleted(worker.pool, executionId, lastStepOutput);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
await worker.captureError(error, {
|
|
85
|
+
source: "workflow",
|
|
86
|
+
category: "server",
|
|
87
|
+
level: "error",
|
|
88
|
+
statusCode: 500,
|
|
89
|
+
metadata: {
|
|
90
|
+
workflowName: result.workflow_name,
|
|
91
|
+
workflowVersion: result.workflow_version,
|
|
92
|
+
executionId,
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
await markWorkflowExecutionFailed(worker.pool, executionId, error);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ErrorRequestHandler } from "express";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import type { Client } from "@elastic/elasticsearch";
|
|
4
|
+
import { type ErrorCategory, type ErrorLevel, type ErrorRow, type ErrorSource } from "../../persistence/repositories/errors/errorRepository.js";
|
|
5
|
+
export interface NormalizedError {
|
|
6
|
+
name: string;
|
|
7
|
+
message: string;
|
|
8
|
+
stack?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ErrorEngineOptions {
|
|
11
|
+
pool: Pool;
|
|
12
|
+
elasticsearchClient: Client;
|
|
13
|
+
}
|
|
14
|
+
export interface CaptureErrorOptions {
|
|
15
|
+
source: ErrorSource;
|
|
16
|
+
category?: ErrorCategory;
|
|
17
|
+
level?: ErrorLevel;
|
|
18
|
+
statusCode?: number;
|
|
19
|
+
metadata?: unknown;
|
|
20
|
+
occurredAt?: Date;
|
|
21
|
+
}
|
|
22
|
+
export type CaptureErrorFunction = (error: unknown, options: CaptureErrorOptions) => Promise<ErrorRow | undefined>;
|
|
23
|
+
export declare function createErrorHandler(options: ErrorEngineOptions): ErrorRequestHandler;
|
|
24
|
+
export declare function captureError(engineOptions: ErrorEngineOptions, error: unknown, captureOptions: CaptureErrorOptions): Promise<ErrorRow | undefined>;
|
|
25
|
+
export declare function normalizeError(error: unknown): NormalizedError;
|
|
26
|
+
export declare function getStatusCode(error: unknown, responseStatusCode: number): number;
|
|
27
|
+
export declare function getErrorCategory(statusCode: number): ErrorCategory;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { getCurrentTraceId, getCurrentSpanId } from "../../runtime/tracing/traceContext.js";
|
|
3
|
+
import { createError, } from "../../persistence/repositories/errors/errorRepository.js";
|
|
4
|
+
import { indexError } from "../../search/elasticsearch/indexer.js";
|
|
5
|
+
export function createErrorHandler(options) {
|
|
6
|
+
return async function flowwatchErrorHandler(error, req, res, next) {
|
|
7
|
+
const normalizedError = normalizeError(error);
|
|
8
|
+
const statusCode = getStatusCode(error, res.statusCode);
|
|
9
|
+
// Log every uncaught error with route info so we can trace the primary cause
|
|
10
|
+
console.error(`[Flowwatch] Unhandled error on ${req.method} ${req.originalUrl || req.path}:`, normalizedError.message);
|
|
11
|
+
await captureError(options, error, {
|
|
12
|
+
source: "http",
|
|
13
|
+
category: getErrorCategory(statusCode),
|
|
14
|
+
level: "error",
|
|
15
|
+
statusCode,
|
|
16
|
+
metadata: {
|
|
17
|
+
method: req.method,
|
|
18
|
+
path: req.originalUrl || req.path,
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
if (res.headersSent) {
|
|
22
|
+
return next(error);
|
|
23
|
+
}
|
|
24
|
+
res.status(statusCode).json({
|
|
25
|
+
message: statusCode >= 500 ? "Internal server error" : normalizedError.message,
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export async function captureError(engineOptions, error, captureOptions) {
|
|
30
|
+
const normalizedError = normalizeError(error);
|
|
31
|
+
const statusCode = captureOptions.statusCode ?? getStatusCode(error, 500);
|
|
32
|
+
const category = captureOptions.category ?? getErrorCategory(statusCode);
|
|
33
|
+
const fingerprint = createErrorFingerprint({
|
|
34
|
+
source: captureOptions.source,
|
|
35
|
+
category,
|
|
36
|
+
name: normalizedError.name,
|
|
37
|
+
message: normalizedError.message,
|
|
38
|
+
statusCode,
|
|
39
|
+
});
|
|
40
|
+
try {
|
|
41
|
+
const storedError = await createError(engineOptions.pool, {
|
|
42
|
+
traceId: getCurrentTraceId(),
|
|
43
|
+
spanId: getCurrentSpanId(),
|
|
44
|
+
source: captureOptions.source,
|
|
45
|
+
category,
|
|
46
|
+
level: captureOptions.level ?? "error",
|
|
47
|
+
message: normalizedError.message,
|
|
48
|
+
stack: normalizedError.stack,
|
|
49
|
+
name: normalizedError.name,
|
|
50
|
+
statusCode,
|
|
51
|
+
fingerprint,
|
|
52
|
+
metadata: captureOptions.metadata,
|
|
53
|
+
occurredAt: captureOptions.occurredAt,
|
|
54
|
+
});
|
|
55
|
+
try {
|
|
56
|
+
await indexError(engineOptions.elasticsearchClient, storedError);
|
|
57
|
+
}
|
|
58
|
+
catch (errorIndexingFailure) {
|
|
59
|
+
console.error("Failed to index error", errorIndexingFailure);
|
|
60
|
+
}
|
|
61
|
+
return storedError;
|
|
62
|
+
}
|
|
63
|
+
catch (errorCaptureFailure) {
|
|
64
|
+
console.error("[Flowwatch] Failed to capture error", errorCaptureFailure);
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function normalizeError(error) {
|
|
69
|
+
if (error instanceof Error) {
|
|
70
|
+
return {
|
|
71
|
+
name: error.name,
|
|
72
|
+
message: error.message,
|
|
73
|
+
stack: error.stack,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
if (typeof error === "string") {
|
|
77
|
+
return {
|
|
78
|
+
name: "UnknownError",
|
|
79
|
+
message: error,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
name: "UnknownError",
|
|
84
|
+
message: "Unknown error",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function getStatusCode(error, responseStatusCode) {
|
|
88
|
+
if (typeof error === "object" && error !== null) {
|
|
89
|
+
const errorObject = error;
|
|
90
|
+
if (typeof errorObject.statusCode === "number") {
|
|
91
|
+
return errorObject.statusCode;
|
|
92
|
+
}
|
|
93
|
+
if (typeof errorObject.status === "number") {
|
|
94
|
+
return errorObject.status;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (responseStatusCode >= 400) {
|
|
98
|
+
return responseStatusCode;
|
|
99
|
+
}
|
|
100
|
+
return 500;
|
|
101
|
+
}
|
|
102
|
+
export function getErrorCategory(statusCode) {
|
|
103
|
+
if (statusCode >= 400 && statusCode < 500) {
|
|
104
|
+
return "client";
|
|
105
|
+
}
|
|
106
|
+
if (statusCode >= 500 && statusCode < 600) {
|
|
107
|
+
return "server";
|
|
108
|
+
}
|
|
109
|
+
return "unknown";
|
|
110
|
+
}
|
|
111
|
+
function createErrorFingerprint(input) {
|
|
112
|
+
return createHash("sha256")
|
|
113
|
+
.update(`${input.source}:${input.category}:${input.name}:${input.message}:${input.statusCode}`)
|
|
114
|
+
.digest("hex");
|
|
115
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { FeatureFlagRow, FeatureFlagRuleRow } from "../../persistence/repositories/flags/flagRepository.js";
|
|
2
|
+
import type { FlagContext } from "./types.js";
|
|
3
|
+
export declare function evaluateFlag(flag: FeatureFlagRow | undefined, rules: FeatureFlagRuleRow[], context?: FlagContext): boolean;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { getRolloutBucket } from "./hashRollout.js";
|
|
2
|
+
export function evaluateFlag(flag, rules, context = {}) {
|
|
3
|
+
if (!flag) {
|
|
4
|
+
return false;
|
|
5
|
+
}
|
|
6
|
+
if (!flag.enabled) {
|
|
7
|
+
return false;
|
|
8
|
+
}
|
|
9
|
+
for (const rule of rules) {
|
|
10
|
+
if (rule.enabled && ruleMatches(rule, context)) {
|
|
11
|
+
return true;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
if (flag.rollout_percentage >= 100) {
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
if (flag.rollout_percentage <= 0) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
if (!context.userId) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const bucket = getRolloutBucket(flag.key, context.userId);
|
|
24
|
+
return bucket < flag.rollout_percentage;
|
|
25
|
+
}
|
|
26
|
+
function ruleMatches(rule, context) {
|
|
27
|
+
const contextValue = context[rule.attribute];
|
|
28
|
+
switch (rule.operator) {
|
|
29
|
+
case "equals":
|
|
30
|
+
return contextValue === rule.value;
|
|
31
|
+
case "not_equals":
|
|
32
|
+
return contextValue !== rule.value;
|
|
33
|
+
case "in":
|
|
34
|
+
return Array.isArray(rule.value) && rule.value.includes(contextValue);
|
|
35
|
+
case "not_in":
|
|
36
|
+
return Array.isArray(rule.value) && !rule.value.includes(contextValue);
|
|
37
|
+
case "contains":
|
|
38
|
+
return typeof contextValue === "string" && typeof rule.value === "string" && contextValue.includes(rule.value);
|
|
39
|
+
case "starts_with":
|
|
40
|
+
return typeof contextValue === "string" && typeof rule.value === "string" && contextValue.startsWith(rule.value);
|
|
41
|
+
case "ends_with":
|
|
42
|
+
return typeof contextValue === "string" && typeof rule.value === "string" && contextValue.endsWith(rule.value);
|
|
43
|
+
case "greater_than":
|
|
44
|
+
return typeof contextValue === "number" && typeof rule.value === "number" && contextValue > rule.value;
|
|
45
|
+
case "less_than":
|
|
46
|
+
return typeof contextValue === "number" && typeof rule.value === "number" && contextValue < rule.value;
|
|
47
|
+
default:
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import type { CaptureErrorFunction } from "../errors/errorEngine.js";
|
|
3
|
+
import type { TraceEngine } from "../trace/traceEngine.js";
|
|
4
|
+
import type { EvaluateFlag } from "./types.js";
|
|
5
|
+
import type { Redis } from "ioredis";
|
|
6
|
+
export interface FlagEngine {
|
|
7
|
+
flag: EvaluateFlag;
|
|
8
|
+
}
|
|
9
|
+
export declare function createFlagEngine(pool: Pool, traceEngine: TraceEngine, captureError: CaptureErrorFunction, redisClient: Redis): FlagEngine;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getFlagByKey, listFlagRules } from "../../persistence/repositories/flags/flagRepository.js";
|
|
2
|
+
import { evaluateFlag } from "./evaluateFlag.js";
|
|
3
|
+
export function createFlagEngine(pool, traceEngine, captureError, redisClient) {
|
|
4
|
+
async function flag(key, context = {}) {
|
|
5
|
+
try {
|
|
6
|
+
return await traceEngine.trace("flowwatch.feature_flag.evaluate", "feature_flag", async () => {
|
|
7
|
+
const cacheKey = `flowwatch:flags:${key}`;
|
|
8
|
+
// Cache read — non-fatal, falls back to DB on any Redis error
|
|
9
|
+
try {
|
|
10
|
+
const cachedValue = await redisClient.get(cacheKey);
|
|
11
|
+
if (cachedValue) {
|
|
12
|
+
const cached = JSON.parse(cachedValue);
|
|
13
|
+
return evaluateFlag(cached.flag, cached.rules, context);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Redis unavailable or version mismatch — continue to DB
|
|
18
|
+
}
|
|
19
|
+
const storedFlag = await getFlagByKey(pool, key);
|
|
20
|
+
if (!storedFlag) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const rules = await listFlagRules(pool, key);
|
|
24
|
+
// Cache write — non-fatal
|
|
25
|
+
try {
|
|
26
|
+
await redisClient.set(cacheKey, JSON.stringify({ flag: storedFlag, rules }), "EX", 60);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
// Cache write failed — not critical
|
|
30
|
+
}
|
|
31
|
+
return evaluateFlag(storedFlag, rules, context);
|
|
32
|
+
}, {
|
|
33
|
+
flagKey: key,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
await captureError(error, {
|
|
38
|
+
source: "feature_flag",
|
|
39
|
+
category: "server",
|
|
40
|
+
level: "error",
|
|
41
|
+
statusCode: 500,
|
|
42
|
+
metadata: {
|
|
43
|
+
flagKey: key,
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
throw error;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
flag,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getRolloutBucket(flagKey: string, userId: string): number;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
export function getRolloutBucket(flagKey, userId) {
|
|
3
|
+
const hash = createHash("sha256")
|
|
4
|
+
.update(`${flagKey}:${userId}`)
|
|
5
|
+
.digest("hex");
|
|
6
|
+
const firstEightHexChars = hash.slice(0, 8);
|
|
7
|
+
const value = Number.parseInt(firstEightHexChars, 16);
|
|
8
|
+
return value % 100;
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Client } from "@elastic/elasticsearch";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import { type TraceSpanType, type TraceStatus } from "../../persistence/repositories/traces/traceRepository.js";
|
|
4
|
+
export interface ActiveTraceSpan {
|
|
5
|
+
id: string;
|
|
6
|
+
startedAt: number;
|
|
7
|
+
}
|
|
8
|
+
export interface StartSpanOptions {
|
|
9
|
+
metadata?: unknown;
|
|
10
|
+
}
|
|
11
|
+
export interface EndSpanOptions {
|
|
12
|
+
metadata?: unknown;
|
|
13
|
+
}
|
|
14
|
+
export type TraceCallback<T> = () => T | Promise<T>;
|
|
15
|
+
export type TraceFunction = <T>(name: string, type: TraceSpanType, callback: TraceCallback<T>, metadata?: unknown) => Promise<T>;
|
|
16
|
+
export interface TraceEngine {
|
|
17
|
+
trace: TraceFunction;
|
|
18
|
+
}
|
|
19
|
+
export interface TraceEngineOptions {
|
|
20
|
+
pool: Pool;
|
|
21
|
+
elasticsearchClient: Client;
|
|
22
|
+
}
|
|
23
|
+
export declare function createTraceEngine(options: TraceEngineOptions): TraceEngine;
|
|
24
|
+
export declare function startSpan(pool: Pool, name: string, type: TraceSpanType, options?: StartSpanOptions): Promise<ActiveTraceSpan | undefined>;
|
|
25
|
+
export declare function endSpan(pool: Pool, elasticsearchClient: Client, span: ActiveTraceSpan | undefined, status: TraceStatus, options?: EndSpanOptions): Promise<void>;
|
|
26
|
+
export declare function runInsideSpan<T>(span: ActiveTraceSpan | undefined, callback: TraceCallback<T>): T | Promise<T>;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { createTraceSpan, finishTraceSpan } from "../../persistence/repositories/traces/traceRepository.js";
|
|
2
|
+
import { getCurrentSpanId, getCurrentTraceId, runWithSpanContext } from "../../runtime/tracing/traceContext.js";
|
|
3
|
+
import { indexTraceSpan } from "../../search/elasticsearch/indexer.js";
|
|
4
|
+
import { captureError } from "../errors/errorEngine.js";
|
|
5
|
+
export function createTraceEngine(options) {
|
|
6
|
+
const { pool, elasticsearchClient } = options;
|
|
7
|
+
async function trace(name, type, callback, metadata) {
|
|
8
|
+
const span = await startSpan(pool, name, type, { metadata });
|
|
9
|
+
try {
|
|
10
|
+
const result = await runInsideSpan(span, callback);
|
|
11
|
+
await endSpan(pool, elasticsearchClient, span, "ok");
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
await endSpan(pool, elasticsearchClient, span, "error");
|
|
16
|
+
await captureError(options, error, {
|
|
17
|
+
source: "unknown",
|
|
18
|
+
category: "server",
|
|
19
|
+
level: "error",
|
|
20
|
+
statusCode: 500,
|
|
21
|
+
metadata: {
|
|
22
|
+
spanName: name,
|
|
23
|
+
spanType: type,
|
|
24
|
+
spanMetadata: metadata,
|
|
25
|
+
},
|
|
26
|
+
});
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
trace,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export async function startSpan(pool, name, type, options = {}) {
|
|
35
|
+
const traceId = getCurrentTraceId();
|
|
36
|
+
if (!traceId) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const span = await createTraceSpan(pool, {
|
|
40
|
+
traceId,
|
|
41
|
+
parentSpanId: getCurrentSpanId(),
|
|
42
|
+
name,
|
|
43
|
+
type,
|
|
44
|
+
metadata: options.metadata,
|
|
45
|
+
});
|
|
46
|
+
return {
|
|
47
|
+
id: span.id,
|
|
48
|
+
startedAt: Date.now(),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function endSpan(pool, elasticsearchClient, span, status, options = {}) {
|
|
52
|
+
if (!span) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const finishedSpan = await finishTraceSpan(pool, {
|
|
56
|
+
spanId: span.id,
|
|
57
|
+
status,
|
|
58
|
+
durationMs: Date.now() - span.startedAt,
|
|
59
|
+
metadata: options.metadata,
|
|
60
|
+
});
|
|
61
|
+
if (!finishedSpan) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
await indexTraceSpan(elasticsearchClient, finishedSpan);
|
|
66
|
+
}
|
|
67
|
+
catch (traceIndexingFailure) {
|
|
68
|
+
console.error("Failed to index trace span", traceIndexingFailure);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export function runInsideSpan(span, callback) {
|
|
72
|
+
if (!span) {
|
|
73
|
+
return callback();
|
|
74
|
+
}
|
|
75
|
+
return runWithSpanContext(span.id, callback);
|
|
76
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type WorkflowStepHandler = (input: unknown) => unknown | Promise<unknown>;
|
|
2
|
+
export type WorkflowStepInputResolver = (workflowInput: unknown) => unknown;
|
|
3
|
+
export interface WorkflowStep {
|
|
4
|
+
name: string;
|
|
5
|
+
run: WorkflowStepHandler;
|
|
6
|
+
retries?: number;
|
|
7
|
+
input?: unknown | WorkflowStepInputResolver;
|
|
8
|
+
}
|
|
9
|
+
export interface RegisteredWorkflow {
|
|
10
|
+
name: string;
|
|
11
|
+
steps: WorkflowStep[];
|
|
12
|
+
dbWorkflow: {
|
|
13
|
+
id: string;
|
|
14
|
+
name: string;
|
|
15
|
+
version: number;
|
|
16
|
+
};
|
|
17
|
+
dbSteps: {
|
|
18
|
+
id: string;
|
|
19
|
+
workflowId: string;
|
|
20
|
+
stepIndex: number;
|
|
21
|
+
name: string;
|
|
22
|
+
maxRetries: number;
|
|
23
|
+
}[];
|
|
24
|
+
}
|
|
25
|
+
export type RegisterWorkflow = (name: string, steps: WorkflowStep[]) => Promise<void>;
|
|
26
|
+
export type TriggerWorkflow = (name: string, input?: unknown) => Promise<{
|
|
27
|
+
executionId: string;
|
|
28
|
+
}>;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Pool } from "pg";
|
|
2
|
+
import { type createWorkflowQueue } from "../background/queues/workflowQueue.js";
|
|
3
|
+
import type { TraceEngine } from "../trace/traceEngine.js";
|
|
4
|
+
import type { RegisterWorkflow, RegisteredWorkflow, TriggerWorkflow } from "./types.js";
|
|
5
|
+
export interface WorkflowEngine {
|
|
6
|
+
workflow: RegisterWorkflow;
|
|
7
|
+
trigger: TriggerWorkflow;
|
|
8
|
+
getWorkflow: (name: string) => RegisteredWorkflow | undefined;
|
|
9
|
+
}
|
|
10
|
+
export interface WorkflowEngineOptions {
|
|
11
|
+
pool: Pool;
|
|
12
|
+
workflowQueue: ReturnType<typeof createWorkflowQueue> | null;
|
|
13
|
+
traceEngine: TraceEngine;
|
|
14
|
+
}
|
|
15
|
+
export declare function createWorkflowEngine(options: WorkflowEngineOptions): WorkflowEngine;
|