@malloy-publisher/server 0.0.165 → 0.0.167

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.
Files changed (33) hide show
  1. package/dist/app/api-doc.yaml +107 -0
  2. package/dist/app/assets/{HomePage-QekMXs8r.js → HomePage-D76UaGFV.js} +1 -1
  3. package/dist/app/assets/{MainPage-DAyUfYba.js → MainPage-C9Fr5IN8.js} +1 -1
  4. package/dist/app/assets/{ModelPage-CrMryV1s.js → ModelPage-BkU6HAHA.js} +1 -1
  5. package/dist/app/assets/{PackagePage-DDaABD2A.js → PackagePage-BhE9Wi7b.js} +1 -1
  6. package/dist/app/assets/{ProjectPage-FAYUFGhL.js → ProjectPage-BatZLVap.js} +1 -1
  7. package/dist/app/assets/{RouteError-BKYctANX.js → RouteError-Bo5zJ8Xa.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-DZEVYGW3.js → WorkbookPage-D3rUQZj6.js} +1 -1
  9. package/dist/app/assets/{index-BvVmB5sv.js → index-BLxl0XLH.js} +71 -71
  10. package/dist/app/assets/{index-DWhjtyBB.js → index-hkABoiMV.js} +1 -1
  11. package/dist/app/assets/{index-CsC07BYd.js → index-lhDwptrQ.js} +1 -1
  12. package/dist/app/assets/{index.umd-DvM-lTQa.js → index.umd-BkXQ-YAe.js} +1 -1
  13. package/dist/app/index.html +1 -1
  14. package/dist/instrumentation.js +85955 -88560
  15. package/dist/server.js +197162 -106231
  16. package/package.json +2 -1
  17. package/src/controller/compile.controller.ts +35 -0
  18. package/src/controller/model.controller.ts +20 -9
  19. package/src/health.ts +8 -0
  20. package/src/instrumentation.ts +123 -34
  21. package/src/server.ts +44 -2
  22. package/src/service/connection.spec.ts +1226 -0
  23. package/src/service/connection.ts +114 -12
  24. package/src/service/db_utils.ts +19 -41
  25. package/src/service/gcs_s3_utils.ts +115 -40
  26. package/src/service/model.ts +5 -5
  27. package/src/service/project.ts +120 -1
  28. package/src/service/project_compile.spec.ts +197 -0
  29. package/src/service/project_store.ts +49 -21
  30. package/src/storage/StorageManager.ts +4 -3
  31. package/src/storage/duckdb/schema.ts +6 -5
  32. package/tests/harness/e2e.ts +4 -0
  33. package/tests/harness/mcp_test_setup.ts +6 -2
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.165",
4
+ "version": "0.0.167",
5
5
  "main": "dist/server.js",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.js"
@@ -42,6 +42,7 @@
42
42
  "@modelcontextprotocol/sdk": "^1.13.2",
43
43
  "@opentelemetry/api": "^1.9.0",
44
44
  "@opentelemetry/auto-instrumentations-node": "^0.57.0",
45
+ "@opentelemetry/exporter-prometheus": "^0.212.0",
45
46
  "@opentelemetry/sdk-metrics": "^2.0.0",
46
47
  "@opentelemetry/sdk-node": "^0.200.0",
47
48
  "@opentelemetry/sdk-trace-node": "^2.0.0",
@@ -0,0 +1,35 @@
1
+ import type { LogMessage } from "@malloydata/malloy";
2
+ import { ProjectStore } from "../service/project_store";
3
+
4
+ export class CompileController {
5
+ private projectStore: ProjectStore;
6
+
7
+ constructor(projectStore: ProjectStore) {
8
+ this.projectStore = projectStore;
9
+ }
10
+
11
+ public async compile(
12
+ projectName: string,
13
+ packageName: string,
14
+ modelName: string,
15
+ source: string,
16
+ includeSql: boolean = false,
17
+ ): Promise<{ status: string; problems: LogMessage[]; sql?: string }> {
18
+ const project = await this.projectStore.getProject(projectName, false);
19
+ const { problems, sql } = await project.compileSource(
20
+ packageName,
21
+ modelName,
22
+ source,
23
+ includeSql,
24
+ );
25
+
26
+ // Determine overall status based on presence of errors
27
+ const hasErrors = problems.some((p) => p.severity === "error");
28
+
29
+ return {
30
+ status: hasErrors ? "error" : "success",
31
+ problems: problems,
32
+ ...(sql !== undefined && { sql }),
33
+ };
34
+ }
35
+ }
@@ -38,16 +38,27 @@ export class ModelController {
38
38
  packageName: string,
39
39
  modelPath: string,
40
40
  ): Promise<ApiCompiledModel> {
41
- const project = await this.projectStore.getProject(projectName, false);
42
- const p = await project.getPackage(packageName, false);
43
- const model = p.getModel(modelPath);
44
- if (!model) {
45
- throw new ModelNotFoundError(`${modelPath} does not exist`);
46
- }
47
- if (model.getType() === "notebook") {
48
- throw new ModelNotFoundError(`${modelPath} is a notebook`);
41
+ try {
42
+ const project = await this.projectStore.getProject(projectName, false);
43
+ const p = await project.getPackage(packageName, false);
44
+ const model = p.getModel(modelPath);
45
+ if (!model) {
46
+ throw new ModelNotFoundError(`${modelPath} does not exist`);
47
+ }
48
+ if (model.getType() === "notebook") {
49
+ throw new ModelNotFoundError(`${modelPath} is a notebook`);
50
+ }
51
+ return await model.getModel();
52
+ } catch (error) {
53
+ // Re-throw ModelNotFoundError as-is
54
+ if (error instanceof ModelNotFoundError) {
55
+ throw error;
56
+ }
57
+ // Wrap other errors with more context
58
+ throw new Error(
59
+ `Failed to get model ${modelPath} from package ${packageName} in project ${projectName}: ${error}`,
60
+ );
49
61
  }
50
- return model.getModel();
51
62
  }
52
63
 
53
64
  public async getNotebook(
package/src/health.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import { Express, NextFunction, Request, Response } from "express";
14
14
  import { Server } from "http";
15
15
  import { components } from "./api";
16
+ import { shutdownSDK } from "./instrumentation";
16
17
  import { logger } from "./logger";
17
18
  export type OperationalState =
18
19
  components["schemas"]["ServerStatus"]["operationalState"];
@@ -112,6 +113,13 @@ export function registerSignalHandlers(
112
113
  closeServer(mcpServer, "MCP server"),
113
114
  ]);
114
115
 
116
+ try {
117
+ await shutdownSDK();
118
+ logger.info("OpenTelemetry SDK shut down");
119
+ } catch (_error) {
120
+ /* do nothing */
121
+ }
122
+
115
123
  try {
116
124
  logger.close();
117
125
  } catch (_error) {
@@ -1,59 +1,148 @@
1
- import { NodeSDK } from "@opentelemetry/sdk-node";
2
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
3
- import { OTLPMetricExporter } from "@opentelemetry/exporter-metrics-otlp-proto";
4
- import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
1
+ import { metrics } from "@opentelemetry/api";
5
2
  import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node";
3
+ import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
4
+ import { PrometheusExporter } from "@opentelemetry/exporter-prometheus";
5
+ import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
6
+ import {
7
+ ExpressInstrumentation,
8
+ ExpressLayerType,
9
+ } from "@opentelemetry/instrumentation-express";
6
10
  import { resourceFromAttributes } from "@opentelemetry/resources";
11
+ import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
12
+ import { NodeSDK } from "@opentelemetry/sdk-node";
13
+ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
7
14
  import {
8
15
  ATTR_SERVICE_NAME,
9
16
  ATTR_SERVICE_VERSION,
10
17
  } from "@opentelemetry/semantic-conventions";
11
- import { PeriodicExportingMetricReader } from "@opentelemetry/sdk-metrics";
12
- import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
13
- import { BatchLogRecordProcessor } from "@opentelemetry/sdk-logs";
18
+ import type { NextFunction, Request, Response } from "express";
14
19
  import { logger } from "./logger";
15
20
 
21
+ let prometheusExporter: PrometheusExporter | null = null;
22
+ let sdk: NodeSDK | null = null;
23
+
24
+ export function getPrometheusMetricsHandler() {
25
+ if (!prometheusExporter) {
26
+ throw new Error("Prometheus exporter not initialized");
27
+ }
28
+ return (req: Request, res: Response) => {
29
+ prometheusExporter!.getMetricsRequestHandler(req, res);
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Shuts down the OpenTelemetry SDK gracefully.
35
+ * Should be called during application shutdown.
36
+ */
37
+ export async function shutdownSDK(): Promise<void> {
38
+ if (sdk) {
39
+ await sdk.shutdown();
40
+ sdk = null;
41
+ }
42
+ }
43
+
16
44
  function instrument() {
17
45
  const otelCollectorUrl = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
18
- if (!otelCollectorUrl) {
19
- logger.info("No OTLP collector URL found, skipping instrumentation");
20
- return;
21
- }
22
46
 
23
- logger.info(
24
- `Initializing OpenTelemetry SDK with OTLP collector at ${otelCollectorUrl}`,
25
- );
26
- const traceExporter = new OTLPTraceExporter({
27
- url: `${otelCollectorUrl}/v1/traces`,
28
- headers: {},
29
- });
30
- const metricExporter = new OTLPMetricExporter({
31
- url: `${otelCollectorUrl}/v1/metrics`,
32
- headers: {},
33
- });
34
- const logExporter = new OTLPLogExporter({
35
- url: `${otelCollectorUrl}/v1/logs`,
36
- headers: {},
47
+ prometheusExporter = new PrometheusExporter({
48
+ preventServerStart: true,
37
49
  });
38
50
 
39
- const sdk = new NodeSDK({
40
- serviceName: "publisher",
51
+ const instrumentations = [
52
+ getNodeAutoInstrumentations(),
53
+ new ExpressInstrumentation({
54
+ ignoreLayersType: [ExpressLayerType.MIDDLEWARE],
55
+ ignoreLayers: [/\/health/, /\/metrics/],
56
+ }),
57
+ ];
58
+
59
+ sdk = new NodeSDK({
41
60
  resource: resourceFromAttributes({
42
61
  [ATTR_SERVICE_NAME]: "publisher",
43
62
  [ATTR_SERVICE_VERSION]: "1.0.0",
44
63
  }),
45
64
  autoDetectResources: true,
46
- traceExporter: traceExporter,
47
- metricReader: new PeriodicExportingMetricReader({
48
- exporter: metricExporter,
65
+ metricReader: prometheusExporter,
66
+ instrumentations,
67
+ ...(otelCollectorUrl && {
68
+ spanProcessors: [
69
+ new BatchSpanProcessor(
70
+ new OTLPTraceExporter({
71
+ url: `${otelCollectorUrl}/v1/traces`,
72
+ }),
73
+ ),
74
+ ],
75
+ logRecordProcessors: [
76
+ new BatchLogRecordProcessor(
77
+ new OTLPLogExporter({
78
+ url: `${otelCollectorUrl}/v1/logs`,
79
+ }),
80
+ ),
81
+ ],
49
82
  }),
50
- instrumentations: [getNodeAutoInstrumentations()],
51
- spanProcessors: [new BatchSpanProcessor(traceExporter)],
52
- logRecordProcessors: [new BatchLogRecordProcessor(logExporter)],
53
83
  });
54
84
 
55
85
  sdk.start();
56
- logger.info("Initialized OpenTelemetry SDK");
86
+
87
+ if (otelCollectorUrl) {
88
+ logger.info(
89
+ `OpenTelemetry SDK initialized with OTLP collector at ${otelCollectorUrl}`,
90
+ );
91
+ } else {
92
+ logger.info("OpenTelemetry SDK initialized with Prometheus metrics only");
93
+ }
57
94
  }
58
95
 
59
96
  instrument();
97
+
98
+ // --- HTTP metrics middleware ---
99
+
100
+ const meter = metrics.getMeter("publisher");
101
+
102
+ const httpRequestDuration = meter.createHistogram(
103
+ "http_server_request_duration_ms",
104
+ {
105
+ description: "Duration of HTTP requests in milliseconds",
106
+ unit: "ms",
107
+ advice: {
108
+ explicitBucketBoundaries: [
109
+ 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000, 60000,
110
+ ],
111
+ },
112
+ },
113
+ );
114
+
115
+ const httpRequestCount = meter.createCounter("http_server_requests_total", {
116
+ description: "Total number of HTTP requests",
117
+ });
118
+
119
+ const IGNORED_PATHS = new Set([
120
+ "/health",
121
+ "/health/liveness",
122
+ "/health/readiness",
123
+ "/metrics",
124
+ ]);
125
+
126
+ export function httpMetricsMiddleware(
127
+ req: Request,
128
+ res: Response,
129
+ next: NextFunction,
130
+ ) {
131
+ const start = performance.now();
132
+
133
+ res.on("finish", () => {
134
+ if (IGNORED_PATHS.has(req.path)) return;
135
+
136
+ const duration = performance.now() - start;
137
+ const attrs = {
138
+ "http.method": req.method,
139
+ "http.route": req.route?.path ?? req.path,
140
+ "http.status_code": res.statusCode,
141
+ };
142
+
143
+ httpRequestDuration.record(duration, attrs);
144
+ httpRequestCount.add(1, attrs);
145
+ });
146
+
147
+ next();
148
+ }
package/src/server.ts CHANGED
@@ -1,3 +1,10 @@
1
+ // Pre-load the instrumentation module; the instrumentation module must be loaded before the other imports.
2
+ import "./instrumentation";
3
+ import {
4
+ getPrometheusMetricsHandler,
5
+ httpMetricsMiddleware,
6
+ } from "./instrumentation";
7
+
1
8
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2
9
  import * as bodyParser from "body-parser";
3
10
  import cors from "cors";
@@ -6,6 +13,7 @@ import * as http from "http";
6
13
  import { createProxyMiddleware } from "http-proxy-middleware";
7
14
  import { AddressInfo } from "net";
8
15
  import * as path from "path";
16
+ import { CompileController } from "./controller/compile.controller";
9
17
  import { ConnectionController } from "./controller/connection.controller";
10
18
  import { DatabaseController } from "./controller/database.controller";
11
19
  import { ModelController } from "./controller/model.controller";
@@ -19,6 +27,7 @@ import {
19
27
  registerSignalHandlers,
20
28
  } from "./health";
21
29
  import { logger, loggerMiddleware } from "./logger";
30
+
22
31
  import { initializeMcpServer } from "./mcp/server";
23
32
  import { ProjectStore } from "./service/project_store";
24
33
  // Parse command line arguments
@@ -110,7 +119,7 @@ const isDevelopment = process.env["NODE_ENV"] === "development";
110
119
 
111
120
  const app = express();
112
121
  app.use(loggerMiddleware);
113
-
122
+ app.use(httpMetricsMiddleware);
114
123
  const projectStore = new ProjectStore(SERVER_ROOT);
115
124
  const watchModeController = new WatchModeController(projectStore);
116
125
  const connectionController = new ConnectionController(projectStore);
@@ -118,6 +127,7 @@ const modelController = new ModelController(projectStore);
118
127
  const packageController = new PackageController(projectStore);
119
128
  const databaseController = new DatabaseController(projectStore);
120
129
  const queryController = new QueryController(projectStore);
130
+ const compileController = new CompileController(projectStore);
121
131
 
122
132
  export const mcpApp = express();
123
133
 
@@ -220,7 +230,10 @@ if (!isDevelopment) {
220
230
  target: "http://localhost:5173",
221
231
  changeOrigin: true,
222
232
  ws: true,
223
- pathFilter: (path) => !path.startsWith("/api/"),
233
+ pathFilter: (path) =>
234
+ !path.startsWith("/api/") &&
235
+ !path.startsWith("/metrics") &&
236
+ !path.startsWith("/health"),
224
237
  }),
225
238
  );
226
239
  }
@@ -243,6 +256,15 @@ app.use(bodyParser.json());
243
256
  // Register health check endpoints
244
257
  registerHealthEndpoints(app);
245
258
 
259
+ // Register Prometheus metrics endpoint
260
+ try {
261
+ const metricsHandler = getPrometheusMetricsHandler();
262
+ app.get("/metrics", metricsHandler);
263
+ logger.info("Prometheus metrics endpoint registered at /metrics");
264
+ } catch (error) {
265
+ logger.warn("Failed to register Prometheus metrics endpoint", { error });
266
+ }
267
+
246
268
  // Register draining guard middleware - must be after health endpoints but before other routes
247
269
  app.use(drainingGuard);
248
270
 
@@ -897,6 +919,26 @@ app.get(
897
919
  },
898
920
  );
899
921
 
922
+ app.post(
923
+ `${API_PREFIX}/projects/:projectName/packages/:packageName/models/:modelName/compile`,
924
+ async (req, res) => {
925
+ try {
926
+ const result = await compileController.compile(
927
+ req.params.projectName,
928
+ req.params.packageName,
929
+ req.params.modelName,
930
+ req.body.source,
931
+ req.body.includeSql === true,
932
+ );
933
+ res.status(200).json(result);
934
+ } catch (error) {
935
+ logger.error("Compilation error", { error });
936
+ const { json, status } = internalErrorToHttpError(error as Error);
937
+ res.status(status).json(json);
938
+ }
939
+ },
940
+ );
941
+
900
942
  // Modify the catch-all route to only serve index.html in production
901
943
  if (!isDevelopment) {
902
944
  app.get("*", (_req, res) => res.sendFile(path.resolve(ROOT, "index.html")));