@superblocksteam/sdk 2.0.139-next.1 → 2.0.140-next.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.
Files changed (80) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/application-build.d.mts.map +1 -1
  3. package/dist/application-build.mjs +3 -0
  4. package/dist/application-build.mjs.map +1 -1
  5. package/dist/dev-utils/dev-server-resolve-config.test.d.mts +2 -0
  6. package/dist/dev-utils/dev-server-resolve-config.test.d.mts.map +1 -0
  7. package/dist/dev-utils/dev-server-resolve-config.test.mjs +20 -0
  8. package/dist/dev-utils/dev-server-resolve-config.test.mjs.map +1 -0
  9. package/dist/dev-utils/dev-server-tracing.test.d.mts +2 -0
  10. package/dist/dev-utils/dev-server-tracing.test.d.mts.map +1 -0
  11. package/dist/dev-utils/dev-server-tracing.test.mjs +24 -0
  12. package/dist/dev-utils/dev-server-tracing.test.mjs.map +1 -0
  13. package/dist/dev-utils/dev-server.d.mts +10 -0
  14. package/dist/dev-utils/dev-server.d.mts.map +1 -1
  15. package/dist/dev-utils/dev-server.mjs +20 -1
  16. package/dist/dev-utils/dev-server.mjs.map +1 -1
  17. package/dist/react-compiler-runtime-shim.d.mts +23 -0
  18. package/dist/react-compiler-runtime-shim.d.mts.map +1 -0
  19. package/dist/react-compiler-runtime-shim.mjs +44 -0
  20. package/dist/react-compiler-runtime-shim.mjs.map +1 -0
  21. package/dist/react-compiler-runtime-shim.test.d.mts +2 -0
  22. package/dist/react-compiler-runtime-shim.test.d.mts.map +1 -0
  23. package/dist/react-compiler-runtime-shim.test.mjs +23 -0
  24. package/dist/react-compiler-runtime-shim.test.mjs.map +1 -0
  25. package/dist/telemetry/attributes.d.ts +0 -1
  26. package/dist/telemetry/attributes.d.ts.map +1 -1
  27. package/dist/telemetry/attributes.js +0 -1
  28. package/dist/telemetry/attributes.js.map +1 -1
  29. package/dist/telemetry/identity.d.ts +29 -0
  30. package/dist/telemetry/identity.d.ts.map +1 -0
  31. package/dist/telemetry/identity.js +9 -0
  32. package/dist/telemetry/identity.js.map +1 -0
  33. package/dist/telemetry/identity.test.d.ts +2 -0
  34. package/dist/telemetry/identity.test.d.ts.map +1 -0
  35. package/dist/telemetry/identity.test.js +25 -0
  36. package/dist/telemetry/identity.test.js.map +1 -0
  37. package/dist/telemetry/index.d.ts.map +1 -1
  38. package/dist/telemetry/index.js +55 -45
  39. package/dist/telemetry/index.js.map +1 -1
  40. package/dist/telemetry/index.test.js +29 -1
  41. package/dist/telemetry/index.test.js.map +1 -1
  42. package/dist/telemetry/local-obs.d.ts +8 -2
  43. package/dist/telemetry/local-obs.d.ts.map +1 -1
  44. package/dist/telemetry/local-obs.js +8 -3
  45. package/dist/telemetry/local-obs.js.map +1 -1
  46. package/dist/telemetry/otlp-config.d.ts +74 -0
  47. package/dist/telemetry/otlp-config.d.ts.map +1 -0
  48. package/dist/telemetry/otlp-config.js +204 -0
  49. package/dist/telemetry/otlp-config.js.map +1 -0
  50. package/dist/telemetry/otlp-config.test.d.ts +2 -0
  51. package/dist/telemetry/otlp-config.test.d.ts.map +1 -0
  52. package/dist/telemetry/otlp-config.test.js +156 -0
  53. package/dist/telemetry/otlp-config.test.js.map +1 -0
  54. package/dist/telemetry/preload.d.ts +23 -0
  55. package/dist/telemetry/preload.d.ts.map +1 -0
  56. package/dist/telemetry/preload.js +92 -0
  57. package/dist/telemetry/preload.js.map +1 -0
  58. package/dist/telemetry/util.d.ts +3 -3
  59. package/dist/telemetry/util.d.ts.map +1 -1
  60. package/dist/telemetry/util.js +6 -32
  61. package/dist/telemetry/util.js.map +1 -1
  62. package/package.json +18 -9
  63. package/src/application-build.mts +4 -0
  64. package/src/dev-utils/dev-server-resolve-config.test.mts +29 -0
  65. package/src/dev-utils/dev-server-tracing.test.mts +32 -0
  66. package/src/dev-utils/dev-server.mts +22 -1
  67. package/src/react-compiler-runtime-shim.mts +50 -0
  68. package/src/react-compiler-runtime-shim.test.mts +26 -0
  69. package/src/telemetry/attributes.ts +0 -1
  70. package/src/telemetry/identity.test.ts +30 -0
  71. package/src/telemetry/identity.ts +40 -0
  72. package/src/telemetry/index.test.ts +36 -1
  73. package/src/telemetry/index.ts +58 -47
  74. package/src/telemetry/local-obs.ts +8 -3
  75. package/src/telemetry/otlp-config.test.ts +213 -0
  76. package/src/telemetry/otlp-config.ts +250 -0
  77. package/src/telemetry/preload.ts +113 -0
  78. package/src/telemetry/util.ts +6 -32
  79. package/tsconfig.tsbuildinfo +1 -1
  80. package/turbo.json +0 -1
@@ -0,0 +1,50 @@
1
+ import { createRequire } from "node:module";
2
+
3
+ import type { Alias } from "vite";
4
+
5
+ import { getLogger } from "./telemetry/logging.js";
6
+
7
+ // Module-scoped require for resolving sibling dependencies by absolute path.
8
+ // `createRequire` works under both the Node runtime and the Vitest module
9
+ // runner, unlike top-level `import.meta.resolve`.
10
+ const sdkRequire = createRequire(import.meta.url);
11
+ const logger = getLogger();
12
+
13
+ /**
14
+ * Vite alias mapping the React 19-only `react/compiler-runtime` specifier to
15
+ * the React-team `react-compiler-runtime` shim, which is API-compatible on
16
+ * React 17/18. Shared by the live-edit dev server and the production build so
17
+ * that user code (or a dependency) emitting `react/compiler-runtime` imports
18
+ * resolves against our React 18.3.1 runtime instead of crashing Vite
19
+ * resolution with `Missing "./compiler-runtime" specifier in "react" package`
20
+ * (APPS-2887).
21
+ *
22
+ * The exact-match string `find` fires only for `react/compiler-runtime`, never
23
+ * `react`, `react/jsx-runtime`, or `react-dom`.
24
+ *
25
+ * Returns `null` (and logs) if the shim package cannot be resolved — e.g. a
26
+ * corrupted or partial SDK install. Skipping the alias keeps the dev server /
27
+ * build starting for every other app instead of throwing synchronously inside
28
+ * `createServer()`/`build()` and permanently rejecting `vitePromise`, which
29
+ * would reproduce the very infinite-spinner symptom this fix removes. Only an
30
+ * app that actually imports `react/compiler-runtime` would then hit the
31
+ * original (now clearly-logged) resolution error.
32
+ */
33
+ export function reactCompilerRuntimeAlias(
34
+ // Resolver seam: defaults to the real module resolution; overridable in tests.
35
+ resolve: (id: string) => string = (id) => sdkRequire.resolve(id),
36
+ ): Alias | null {
37
+ let replacement: string;
38
+ try {
39
+ replacement = resolve("react-compiler-runtime");
40
+ } catch {
41
+ logger.warn(
42
+ "[APPS-2887] Could not resolve the 'react-compiler-runtime' shim; " +
43
+ "skipping the react/compiler-runtime alias. Reinstall the SDK " +
44
+ "dependencies if an app needs it (apps that do not import " +
45
+ "react/compiler-runtime are unaffected).",
46
+ );
47
+ return null;
48
+ }
49
+ return { find: "react/compiler-runtime", replacement };
50
+ }
@@ -0,0 +1,26 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isAbsolute } from "node:path";
3
+
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ import { reactCompilerRuntimeAlias } from "./react-compiler-runtime-shim.mjs";
7
+
8
+ describe("reactCompilerRuntimeAlias", () => {
9
+ it("maps react/compiler-runtime to an existing absolute shim path", () => {
10
+ const alias = reactCompilerRuntimeAlias();
11
+ expect(alias).not.toBeNull();
12
+ expect(alias!.find).toBe("react/compiler-runtime");
13
+ expect(typeof alias!.replacement).toBe("string");
14
+ expect(isAbsolute(alias!.replacement as string)).toBe(true);
15
+ expect(existsSync(alias!.replacement as string)).toBe(true);
16
+ });
17
+
18
+ it("returns null instead of throwing when the shim cannot be resolved", () => {
19
+ // Simulate a corrupted/partial install where the shim package is absent.
20
+ // The dev server must keep starting (no permanently-rejected vitePromise).
21
+ const alias = reactCompilerRuntimeAlias(() => {
22
+ throw new Error("Cannot find module 'react-compiler-runtime'");
23
+ });
24
+ expect(alias).toBeNull();
25
+ });
26
+ });
@@ -2,7 +2,6 @@
2
2
  export const ATTR_DEPLOYMENT_ENVIRONMENT = "deployment.environment";
3
3
  // NOTE: @joeyagreco - this can be used to determine if we are using mock-csb, staging-csb, prod-csb, etc
4
4
  export const ATTR_SUPERBLOCKS_BASE_URL = "superblocks.base_url";
5
- export const ATTR_SUPERBLOCKS_CLI_TOKEN = "superblocks.cli_token";
6
5
  export const ATTR_SUPERBLOCKS_IS_CSB = "superblocks.is_csb";
7
6
  export const ATTR_SUPERBLOCKS_USER_ID = "user-id";
8
7
  export const ATTR_SUPERBLOCKS_CSB_ID = "csb_id";
@@ -0,0 +1,30 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+
3
+ describe("telemetry identity", () => {
4
+ afterEach(async () => {
5
+ const { setTelemetryIdentity } = await import("./identity.js");
6
+ setTelemetryIdentity({});
7
+ vi.resetModules();
8
+ });
9
+
10
+ it("drops undefined values and returns the rest", async () => {
11
+ const { setTelemetryIdentity, getTelemetryIdentity } =
12
+ await import("./identity.js");
13
+ setTelemetryIdentity({ "application-id": "app-1", branch: undefined });
14
+ expect(getTelemetryIdentity()).toEqual({ "application-id": "app-1" });
15
+ });
16
+
17
+ it("shares identity across module instances, as when the CLI bundle and the preload's node_modules copy each load their own", async () => {
18
+ const writer = await import("./identity.js");
19
+ writer.setTelemetryIdentity({ "application-id": "app-1" });
20
+
21
+ // A fresh module registry stands in for the second copy of this module
22
+ // that esbuild bakes into the packaged CLI.
23
+ vi.resetModules();
24
+ const reader = await import("./identity.js");
25
+
26
+ expect(reader.getTelemetryIdentity()).toEqual({
27
+ "application-id": "app-1",
28
+ });
29
+ });
30
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Mutable holder for dev-server telemetry identity that is only known after
3
+ * connect: application id, branch, user, and org.
4
+ *
5
+ * The preload starts OpenTelemetry early (before the ai-service loads) with only
6
+ * the static resource it can derive from the environment, and passes
7
+ * `getTelemetryIdentity` as the SDK's dynamic attribute source.
8
+ * `configureTelemetry` fills this in once it has fetched the application config,
9
+ * so spans and exported log records created after connect carry the full
10
+ * identity — the same set the SDK used to put on the resource — without
11
+ * re-initializing the SDK. Earlier startup signals simply carry the static
12
+ * resource. Metrics deliberately do not carry identity (cardinality).
13
+ *
14
+ * The holder lives on `globalThis`, not in a module variable: the packaged CLI
15
+ * contains TWO copies of this module (one esbuild-bundled into the CLI dist,
16
+ * one under node_modules that the bin's `telemetry-preload` import resolves
17
+ * to). The preload's copy hands the getter to the exporting SDK while the
18
+ * bundled copy's `configureTelemetry` calls the setter, so a per-module
19
+ * variable would split into two holders and identity would never reach an
20
+ * exported span or log record.
21
+ */
22
+ declare global {
23
+ var __superblocksTelemetryIdentity: Record<string, string> | undefined;
24
+ }
25
+
26
+ /** Replaces the identity. Undefined values are dropped so absent fields are omitted. */
27
+ export function setTelemetryIdentity(
28
+ next: Record<string, string | undefined>,
29
+ ): void {
30
+ globalThis.__superblocksTelemetryIdentity = Object.fromEntries(
31
+ Object.entries(next).filter(
32
+ (entry): entry is [string, string] => entry[1] != null,
33
+ ),
34
+ );
35
+ }
36
+
37
+ /** Current identity; passed to the SDK as the dynamic span-attribute source. */
38
+ export function getTelemetryIdentity(): Record<string, string> {
39
+ return globalThis.__superblocksTelemetryIdentity ?? {};
40
+ }
@@ -42,6 +42,8 @@ vi.mock("./logging.js", () => ({
42
42
  }),
43
43
  }));
44
44
 
45
+ import { FeatureFlags } from "../flag.js";
46
+ import type { ApplicationConfigWithTokenConfigAndUserInfo } from "../types/index.js";
45
47
  // Import type for module shape
46
48
  import type * as TelemetryModule from "./index.js";
47
49
 
@@ -63,8 +65,10 @@ describe("CLI SDK Telemetry Integration", () => {
63
65
  }
64
66
  });
65
67
 
66
- beforeEach(() => {
68
+ beforeEach(async () => {
67
69
  vi.clearAllMocks();
70
+ // Each test starts unconfigured; repeat-call behavior is tested explicitly.
71
+ await telemetryModule.shutdownTelemetry();
68
72
  });
69
73
 
70
74
  describe("configureTelemetry", () => {
@@ -84,6 +88,37 @@ describe("CLI SDK Telemetry Integration", () => {
84
88
 
85
89
  expect(ddTrace.default.init).toHaveBeenCalled();
86
90
  });
91
+
92
+ it("refreshes identity on a repeat call without tearing down the pipeline", async () => {
93
+ const ddTrace = await import("dd-trace");
94
+ const { isTelemetryInitialized } =
95
+ await import("@superblocksteam/telemetry");
96
+ const { getTelemetryIdentity } = await import("./identity.js");
97
+
98
+ const applicationConfig: ApplicationConfigWithTokenConfigAndUserInfo = {
99
+ id: "app-2",
100
+ branchName: "main",
101
+ userId: "user-1",
102
+ userEmail: "dev@example.com",
103
+ organizationId: "org-1",
104
+ featureFlags: new FeatureFlags({}),
105
+ superblocksBaseUrl: "https://app.superblocks.com",
106
+ token: "test1234",
107
+ };
108
+
109
+ await telemetryModule.configureTelemetry();
110
+ const result =
111
+ await telemetryModule.configureTelemetry(applicationConfig);
112
+
113
+ expect(result).toBe(true);
114
+ // The running SDK and dd-trace survive; only identity is refreshed.
115
+ expect(isTelemetryInitialized()).toBe(true);
116
+ expect(ddTrace.default.init).toHaveBeenCalledTimes(1);
117
+ expect(getTelemetryIdentity()).toMatchObject({
118
+ "application-id": "app-2",
119
+ "organization-id": "org-1",
120
+ });
121
+ });
87
122
  });
88
123
 
89
124
  describe("getTracer", () => {
@@ -26,12 +26,12 @@ import packageJson from "../../package.json" with { type: "json" };
26
26
  import type { ApplicationConfigWithTokenConfigAndUserInfo } from "../types/index.js";
27
27
  import {
28
28
  ATTR_SUPERBLOCKS_BASE_URL,
29
- ATTR_SUPERBLOCKS_CLI_TOKEN,
30
29
  ATTR_SUPERBLOCKS_CSB_ID,
31
30
  ATTR_SUPERBLOCKS_IS_CSB,
32
31
  ATTR_SUPERBLOCKS_USER_ID,
33
32
  getEnvironmentFromHostname,
34
33
  } from "./attributes.js";
34
+ import { getTelemetryIdentity, setTelemetryIdentity } from "./identity.js";
35
35
  import { applyLocalObsEnvVars, isLocalObsEnabled } from "./local-obs.js";
36
36
  import { getLogger as getStandardLogger } from "./logging.js";
37
37
  import { getConfiguration, SERVICE_NAME } from "./util.js";
@@ -110,8 +110,28 @@ export async function configureTelemetry(
110
110
  applicationConfig?: ApplicationConfigWithTokenConfigAndUserInfo,
111
111
  ): Promise<boolean> {
112
112
  const logger = getStandardLogger();
113
+
114
+ // The application, user, and org are known only at connect, so they are
115
+ // stamped onto every span and exported log record via the SDK's dynamic
116
+ // attribute source rather than the resource. This restores the identity the
117
+ // SDK used to put on the resource, without re-initializing (which would churn
118
+ // instrumentation). The CLI token is deliberately NOT included: it is a
119
+ // credential and does not belong in telemetry.
120
+ const identity = {
121
+ [OBS_TAG_APPLICATION_ID]: applicationConfig?.id,
122
+ [OBS_TAG_BRANCH]: applicationConfig?.branchName,
123
+ [OBS_TAG_USER_EMAIL]: applicationConfig?.userEmail,
124
+ [ATTR_SUPERBLOCKS_USER_ID]: applicationConfig?.userId,
125
+ [OBS_TAG_ORG_ID]: applicationConfig?.organizationId,
126
+ };
127
+
128
+ // Repeat calls (e.g. auth hot-reload) must not tear down a working pipeline:
129
+ // a shutdown+reinit loses the global provider registration (the OTel API
130
+ // rejects a second registration), leaving all instrumentation pointed at dead
131
+ // providers. Refresh the identity and keep the running SDK and dd-trace.
113
132
  if (configured) {
114
- await shutdownTelemetry();
133
+ setTelemetryIdentity(identity);
134
+ return true;
115
135
  }
116
136
 
117
137
  configured = true;
@@ -128,6 +148,8 @@ export async function configureTelemetry(
128
148
  `[INIT] Initializing telemetry for service ${config.serviceName} at Superblocks instance ${config.superblocksHostname}`,
129
149
  );
130
150
 
151
+ setTelemetryIdentity(identity);
152
+
131
153
  // Check for local observability mode (SUPERBLOCKS_LOCAL_OBS=true)
132
154
  const isLocalObs = isLocalObsEnabled();
133
155
 
@@ -157,7 +179,6 @@ export async function configureTelemetry(
157
179
  plugins: false,
158
180
  tags: {
159
181
  [ATTR_SUPERBLOCKS_BASE_URL]: config.superblocksHostname,
160
- [ATTR_SUPERBLOCKS_CLI_TOKEN]: config.token,
161
182
  [ATTR_SUPERBLOCKS_IS_CSB]: !!process.env.SUPERBLOCKS_IS_CSB,
162
183
  [ATTR_SUPERBLOCKS_CSB_ID]: process.env.SUPERBLOCKS_IS_CSB
163
184
  ? os.hostname()
@@ -179,51 +200,41 @@ export async function configureTelemetry(
179
200
  } for service ${config.serviceName}`,
180
201
  );
181
202
 
182
- // Configure OpenTelemetry for APM traces and logs via shared telemetry package
183
- const policy = getDefaultPolicy(deploymentType);
184
-
185
- try {
186
- initNodeTelemetry(
187
- {
188
- serviceName: config.serviceName,
189
- serviceVersion: packageJson.version,
190
- environment: getEnvironmentFromHostname(config.superblocksHostname),
191
- otlpUrl: config.otlpBaseUrl,
192
- resourceAttributes: {
193
- [ATTR_SUPERBLOCKS_BASE_URL]: config.superblocksHostname,
194
- [ATTR_SUPERBLOCKS_CLI_TOKEN]: config.token,
195
- [ATTR_SUPERBLOCKS_IS_CSB]: String(!!process.env.SUPERBLOCKS_IS_CSB),
196
- ...(process.env.SUPERBLOCKS_IS_CSB && {
197
- [ATTR_SUPERBLOCKS_CSB_ID]: os.hostname(),
198
- }),
199
- ...(applicationConfig?.id && {
200
- [OBS_TAG_APPLICATION_ID]: applicationConfig.id,
201
- }),
202
- ...(applicationConfig?.branchName && {
203
- [OBS_TAG_BRANCH]: applicationConfig.branchName,
204
- }),
205
- ...(applicationConfig?.userEmail && {
206
- [OBS_TAG_USER_EMAIL]: applicationConfig.userEmail,
207
- }),
208
- ...(applicationConfig?.userId && {
209
- [ATTR_SUPERBLOCKS_USER_ID]: applicationConfig.userId,
210
- }),
211
- ...(applicationConfig?.organizationId && {
212
- [OBS_TAG_ORG_ID]: applicationConfig.organizationId,
213
- }),
203
+ // Configure OpenTelemetry for APM traces and logs via shared telemetry
204
+ // package. When the early preload already started the SDK, skip: identity
205
+ // flows through the dynamic attribute source above, and constructing
206
+ // instrumentations here would only leave self-enabled orphans behind
207
+ // (initNodeTelemetry would return the existing instance and discard them).
208
+ if (!isTelemetryInitialized()) {
209
+ const policy = getDefaultPolicy(deploymentType);
210
+ try {
211
+ initNodeTelemetry(
212
+ {
213
+ serviceName: config.serviceName,
214
+ serviceVersion: packageJson.version,
215
+ environment: getEnvironmentFromHostname(config.superblocksHostname),
216
+ otlpUrl: config.otlpBaseUrl,
217
+ resourceAttributes: {
218
+ [ATTR_SUPERBLOCKS_BASE_URL]: config.superblocksHostname,
219
+ [ATTR_SUPERBLOCKS_IS_CSB]: String(!!process.env.SUPERBLOCKS_IS_CSB),
220
+ ...(process.env.SUPERBLOCKS_IS_CSB && {
221
+ [ATTR_SUPERBLOCKS_CSB_ID]: os.hostname(),
222
+ }),
223
+ },
224
+ instrumentations: [
225
+ new HttpInstrumentation(),
226
+ new ExpressInstrumentation(),
227
+ ],
228
+ logsEnabled: true,
229
+ dynamicAttributes: getTelemetryIdentity,
214
230
  },
215
- instrumentations: [
216
- new HttpInstrumentation(),
217
- new ExpressInstrumentation(),
218
- ],
219
- logsEnabled: true,
220
- },
221
- policy,
222
- );
223
- } catch (error) {
224
- console.error("[Telemetry] Failed to initialize OpenTelemetry:", error);
225
- configured = false;
226
- return false;
231
+ policy,
232
+ );
233
+ } catch (error) {
234
+ console.error("[Telemetry] Failed to initialize OpenTelemetry:", error);
235
+ configured = false;
236
+ return false;
237
+ }
227
238
  }
228
239
 
229
240
  return true;
@@ -5,11 +5,10 @@
5
5
  * all environment variables needed for local observability testing with the
6
6
  * LGTM stack (Loki, Grafana, Tempo, Mimir).
7
7
  *
8
- * This eliminates the need to manually set 9+ environment variables:
8
+ * This eliminates the need to manually set 8+ environment variables:
9
9
  * - SUPERBLOCKS_DEPLOYMENT_TYPE
10
10
  * - SUPERBLOCKS_LLMOBS_ENABLED
11
11
  * - OTEL_EXPORTER_OTLP_ENDPOINT
12
- * - SUPERBLOCKS_OTEL_COLLECTOR_URL
13
12
  * - DD_LLMOBS_ENABLED
14
13
  * - DD_LLMOBS_ML_APP
15
14
  * - DD_LLMOBS_AGENTLESS_ENABLED
@@ -21,6 +20,13 @@
21
20
  *
22
21
  * Or with custom OTEL endpoint:
23
22
  * SUPERBLOCKS_LOCAL_OBS=true OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 pnpm start:all
23
+ *
24
+ * Note: this mode changes export POLICY (sampling, log levels, LLMObs), not the
25
+ * dev-server's OTLP endpoint. The dev-server always exports through the
26
+ * Superblocks server proxy at <SUPERBLOCKS_BASE_URL>/api, so with an auth file
27
+ * pointing at a remote instance, a standalone `SUPERBLOCKS_LOCAL_OBS=true
28
+ * superblocks dev` sends its telemetry to that instance, not to the local
29
+ * collector. Run against a local stack to keep telemetry local.
24
30
  */
25
31
 
26
32
  import { DeploymentTypeEnum } from "@superblocksteam/shared";
@@ -122,7 +128,6 @@ export function applyLocalObsEnvVars(): void {
122
128
  SUPERBLOCKS_DEPLOYMENT_TYPE: config.deploymentType,
123
129
  SUPERBLOCKS_LLMOBS_ENABLED: "true",
124
130
  OTEL_EXPORTER_OTLP_ENDPOINT: config.otelEndpoint,
125
- SUPERBLOCKS_OTEL_COLLECTOR_URL: config.otelEndpoint,
126
131
  DD_LLMOBS_ENABLED: "1",
127
132
  DD_LLMOBS_ML_APP: config.llmobs.mlApp,
128
133
  DD_LLMOBS_AGENTLESS_ENABLED: "1",
@@ -0,0 +1,213 @@
1
+ import * as http from "node:http";
2
+ import type { AddressInfo } from "node:net";
3
+
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ import {
7
+ buildDeltaCounterPayload,
8
+ deploymentEnvironment,
9
+ emitOtlpCounter,
10
+ otlpBaseUrl,
11
+ otlpMetricsUrl,
12
+ } from "./otlp-config.js";
13
+
14
+ describe("otlpBaseUrl", () => {
15
+ it("returns <origin>/api for a valid base URL, dropping any path", () => {
16
+ expect(otlpBaseUrl("https://app.superblocks.com/some/path")).toBe(
17
+ "https://app.superblocks.com/api",
18
+ );
19
+ });
20
+
21
+ it("returns undefined when the base URL is missing", () => {
22
+ expect(otlpBaseUrl(undefined)).toBeUndefined();
23
+ });
24
+
25
+ it("returns undefined on an unparseable base URL", () => {
26
+ expect(otlpBaseUrl("not a url")).toBeUndefined();
27
+ });
28
+ });
29
+
30
+ describe("otlpMetricsUrl", () => {
31
+ it("derives <baseUrl origin>/api/v1/metrics from SUPERBLOCKS_BASE_URL", () => {
32
+ expect(
33
+ otlpMetricsUrl({
34
+ SUPERBLOCKS_BASE_URL: "https://pr-19864.superblocks.dev/some/path",
35
+ }),
36
+ ).toBe("https://pr-19864.superblocks.dev/api/v1/metrics");
37
+ });
38
+
39
+ it("always exports through the server proxy: SUPERBLOCKS_OTEL_COLLECTOR_URL alone is ignored", () => {
40
+ // There is no direct-to-collector path anymore; the server proxies OTLP.
41
+ expect(
42
+ otlpMetricsUrl({
43
+ SUPERBLOCKS_OTEL_COLLECTOR_URL: "http://localhost:4318",
44
+ }),
45
+ ).toBeNull();
46
+ });
47
+
48
+ it("returns null when no base URL is present (emission is skipped)", () => {
49
+ expect(otlpMetricsUrl({})).toBeNull();
50
+ });
51
+
52
+ it("returns null on an unparseable base URL", () => {
53
+ expect(otlpMetricsUrl({ SUPERBLOCKS_BASE_URL: "not a url" })).toBeNull();
54
+ });
55
+ });
56
+
57
+ describe("warnIfCollectorUrlIgnored", () => {
58
+ it("warns once (and only once) when the retired direct-export var is set", async () => {
59
+ // The module-level call in otlp-config.ts latches the once-flag against
60
+ // the real process.env at import time, so a shell that exports the var
61
+ // would make the statically imported instance a no-op here. Import a
62
+ // fresh instance with the var stubbed away.
63
+ vi.stubEnv("SUPERBLOCKS_OTEL_COLLECTOR_URL", "");
64
+ vi.resetModules();
65
+ const { warnIfCollectorUrlIgnored } = await import("./otlp-config.js");
66
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
67
+
68
+ warnIfCollectorUrlIgnored({});
69
+ expect(warn).not.toHaveBeenCalled();
70
+
71
+ warnIfCollectorUrlIgnored({
72
+ SUPERBLOCKS_OTEL_COLLECTOR_URL: "http://localhost:4318",
73
+ });
74
+ warnIfCollectorUrlIgnored({
75
+ SUPERBLOCKS_OTEL_COLLECTOR_URL: "http://localhost:4318",
76
+ });
77
+
78
+ expect(warn).toHaveBeenCalledTimes(1);
79
+ expect(warn.mock.calls[0][0]).toContain("SUPERBLOCKS_OTEL_COLLECTOR_URL");
80
+ warn.mockRestore();
81
+ vi.unstubAllEnvs();
82
+ });
83
+ });
84
+
85
+ describe("deploymentEnvironment", () => {
86
+ it("maps an ephemeral pr-<n> host to 'ephemeral'", () => {
87
+ expect(
88
+ deploymentEnvironment({
89
+ SUPERBLOCKS_BASE_URL: "https://pr-19864.superblocks.dev",
90
+ }),
91
+ ).toBe("ephemeral");
92
+ });
93
+
94
+ it("maps localhost to 'local'", () => {
95
+ expect(
96
+ deploymentEnvironment({ SUPERBLOCKS_BASE_URL: "http://localhost:3000" }),
97
+ ).toBe("local");
98
+ });
99
+
100
+ it("maps the production host to 'prod'", () => {
101
+ expect(
102
+ deploymentEnvironment({
103
+ SUPERBLOCKS_BASE_URL: "https://app.superblocks.com",
104
+ }),
105
+ ).toBe("prod");
106
+ });
107
+
108
+ it("falls back to 'other' when no base URL is available", () => {
109
+ expect(deploymentEnvironment({})).toBe("other");
110
+ });
111
+ });
112
+
113
+ describe("buildDeltaCounterPayload", () => {
114
+ it("builds a monotonic DELTA sum with one int data point and the given attributes", () => {
115
+ const payload = buildDeltaCounterPayload({
116
+ name: "dev_server_child_respawn_completed_total",
117
+ description: "desc",
118
+ attributes: { reason: "flip_restart" },
119
+ environment: "ephemeral",
120
+ nowMs: 1_730_000_000_000,
121
+ }) as any;
122
+
123
+ const rm = payload.resourceMetrics[0];
124
+ const resourceAttrs = Object.fromEntries(
125
+ rm.resource.attributes.map((a: any) => [a.key, a.value.stringValue]),
126
+ );
127
+ expect(resourceAttrs["service.name"]).toBe("sdk-dev-server");
128
+ expect(resourceAttrs["deployment.environment.name"]).toBe("ephemeral");
129
+
130
+ const metric = rm.scopeMetrics[0].metrics[0];
131
+ expect(metric.sum.isMonotonic).toBe(true);
132
+ expect(metric.sum.aggregationTemporality).toBe(1); // DELTA
133
+
134
+ const dp = metric.sum.dataPoints[0];
135
+ expect(dp.asInt).toBe("1");
136
+ const dpAttrs = Object.fromEntries(
137
+ dp.attributes.map((a: any) => [a.key, a.value.stringValue]),
138
+ );
139
+ expect(dpAttrs.reason).toBe("flip_restart");
140
+
141
+ // Nanosecond timestamps must be decimal strings, start <= time.
142
+ expect(typeof dp.timeUnixNano).toBe("string");
143
+ expect(BigInt(dp.startTimeUnixNano) <= BigInt(dp.timeUnixNano)).toBe(true);
144
+ expect(dp.timeUnixNano).toBe(
145
+ (BigInt(1_730_000_000_000) * 1_000_000n).toString(),
146
+ );
147
+ });
148
+ });
149
+
150
+ describe("emitOtlpCounter (integration: listens on the OTLP endpoint)", () => {
151
+ let server: http.Server;
152
+ let received: { url?: string; contentType?: string; body: string } | null;
153
+ let port: number;
154
+
155
+ beforeEach(async () => {
156
+ received = null;
157
+ server = http.createServer((req, res) => {
158
+ let body = "";
159
+ req.on("data", (c) => (body += c));
160
+ req.on("end", () => {
161
+ received = {
162
+ url: req.url,
163
+ contentType: req.headers["content-type"],
164
+ body,
165
+ };
166
+ res.writeHead(200, { "content-type": "application/json" });
167
+ res.end("{}");
168
+ });
169
+ });
170
+ await new Promise<void>((resolve) =>
171
+ server.listen(0, "127.0.0.1", resolve),
172
+ );
173
+ port = (server.address() as AddressInfo).port;
174
+ });
175
+
176
+ afterEach(async () => {
177
+ await new Promise<void>((resolve) => server.close(() => resolve()));
178
+ });
179
+
180
+ it("POSTs the counter as OTLP JSON to the server proxy's /api/v1/metrics", async () => {
181
+ await emitOtlpCounter({
182
+ name: "dev_server_child_respawn_completed_total",
183
+ description: "desc",
184
+ attributes: { reason: "flip_restart" },
185
+ env: { SUPERBLOCKS_BASE_URL: `http://127.0.0.1:${port}` },
186
+ // Tests must observe the request, so keep the socket alive (no unref) and
187
+ // await the POST instead of fire-and-forget.
188
+ unref: false,
189
+ timeoutMs: 5000,
190
+ });
191
+
192
+ expect(received).not.toBeNull();
193
+ expect(received!.url).toBe("/api/v1/metrics");
194
+ expect(received!.contentType).toContain("application/json");
195
+
196
+ const parsed = JSON.parse(received!.body);
197
+ const metric = parsed.resourceMetrics[0].scopeMetrics[0].metrics[0];
198
+ expect(metric.sum.dataPoints[0].asInt).toBe("1");
199
+ });
200
+
201
+ it("resolves without throwing and sends nothing when no base URL is configured", async () => {
202
+ await expect(
203
+ emitOtlpCounter({
204
+ name: "dev_server_child_respawn_completed_total",
205
+ description: "desc",
206
+ attributes: { reason: "flip_restart" },
207
+ env: {},
208
+ unref: false,
209
+ }),
210
+ ).resolves.toBeUndefined();
211
+ expect(received).toBeNull();
212
+ });
213
+ });