@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.
- package/.turbo/turbo-build.log +1 -1
- package/dist/application-build.d.mts.map +1 -1
- package/dist/application-build.mjs +3 -0
- package/dist/application-build.mjs.map +1 -1
- package/dist/dev-utils/dev-server-resolve-config.test.d.mts +2 -0
- package/dist/dev-utils/dev-server-resolve-config.test.d.mts.map +1 -0
- package/dist/dev-utils/dev-server-resolve-config.test.mjs +20 -0
- package/dist/dev-utils/dev-server-resolve-config.test.mjs.map +1 -0
- package/dist/dev-utils/dev-server-tracing.test.d.mts +2 -0
- package/dist/dev-utils/dev-server-tracing.test.d.mts.map +1 -0
- package/dist/dev-utils/dev-server-tracing.test.mjs +24 -0
- package/dist/dev-utils/dev-server-tracing.test.mjs.map +1 -0
- package/dist/dev-utils/dev-server.d.mts +10 -0
- package/dist/dev-utils/dev-server.d.mts.map +1 -1
- package/dist/dev-utils/dev-server.mjs +20 -1
- package/dist/dev-utils/dev-server.mjs.map +1 -1
- package/dist/react-compiler-runtime-shim.d.mts +23 -0
- package/dist/react-compiler-runtime-shim.d.mts.map +1 -0
- package/dist/react-compiler-runtime-shim.mjs +44 -0
- package/dist/react-compiler-runtime-shim.mjs.map +1 -0
- package/dist/react-compiler-runtime-shim.test.d.mts +2 -0
- package/dist/react-compiler-runtime-shim.test.d.mts.map +1 -0
- package/dist/react-compiler-runtime-shim.test.mjs +23 -0
- package/dist/react-compiler-runtime-shim.test.mjs.map +1 -0
- package/dist/telemetry/attributes.d.ts +0 -1
- package/dist/telemetry/attributes.d.ts.map +1 -1
- package/dist/telemetry/attributes.js +0 -1
- package/dist/telemetry/attributes.js.map +1 -1
- package/dist/telemetry/identity.d.ts +29 -0
- package/dist/telemetry/identity.d.ts.map +1 -0
- package/dist/telemetry/identity.js +9 -0
- package/dist/telemetry/identity.js.map +1 -0
- package/dist/telemetry/identity.test.d.ts +2 -0
- package/dist/telemetry/identity.test.d.ts.map +1 -0
- package/dist/telemetry/identity.test.js +25 -0
- package/dist/telemetry/identity.test.js.map +1 -0
- package/dist/telemetry/index.d.ts.map +1 -1
- package/dist/telemetry/index.js +55 -45
- package/dist/telemetry/index.js.map +1 -1
- package/dist/telemetry/index.test.js +29 -1
- package/dist/telemetry/index.test.js.map +1 -1
- package/dist/telemetry/local-obs.d.ts +8 -2
- package/dist/telemetry/local-obs.d.ts.map +1 -1
- package/dist/telemetry/local-obs.js +8 -3
- package/dist/telemetry/local-obs.js.map +1 -1
- package/dist/telemetry/otlp-config.d.ts +74 -0
- package/dist/telemetry/otlp-config.d.ts.map +1 -0
- package/dist/telemetry/otlp-config.js +204 -0
- package/dist/telemetry/otlp-config.js.map +1 -0
- package/dist/telemetry/otlp-config.test.d.ts +2 -0
- package/dist/telemetry/otlp-config.test.d.ts.map +1 -0
- package/dist/telemetry/otlp-config.test.js +156 -0
- package/dist/telemetry/otlp-config.test.js.map +1 -0
- package/dist/telemetry/preload.d.ts +23 -0
- package/dist/telemetry/preload.d.ts.map +1 -0
- package/dist/telemetry/preload.js +92 -0
- package/dist/telemetry/preload.js.map +1 -0
- package/dist/telemetry/util.d.ts +3 -3
- package/dist/telemetry/util.d.ts.map +1 -1
- package/dist/telemetry/util.js +6 -32
- package/dist/telemetry/util.js.map +1 -1
- package/package.json +18 -9
- package/src/application-build.mts +4 -0
- package/src/dev-utils/dev-server-resolve-config.test.mts +29 -0
- package/src/dev-utils/dev-server-tracing.test.mts +32 -0
- package/src/dev-utils/dev-server.mts +22 -1
- package/src/react-compiler-runtime-shim.mts +50 -0
- package/src/react-compiler-runtime-shim.test.mts +26 -0
- package/src/telemetry/attributes.ts +0 -1
- package/src/telemetry/identity.test.ts +30 -0
- package/src/telemetry/identity.ts +40 -0
- package/src/telemetry/index.test.ts +36 -1
- package/src/telemetry/index.ts +58 -47
- package/src/telemetry/local-obs.ts +8 -3
- package/src/telemetry/otlp-config.test.ts +213 -0
- package/src/telemetry/otlp-config.ts +250 -0
- package/src/telemetry/preload.ts +113 -0
- package/src/telemetry/util.ts +6 -32
- package/tsconfig.tsbuildinfo +1 -1
- 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", () => {
|
package/src/telemetry/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
[
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
+
});
|