@superblocksteam/sdk 2.0.149 → 2.0.150-next.1

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 (49) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/cli-replacement/dependency-install-classifier.d.mts +3 -1
  3. package/dist/cli-replacement/dependency-install-classifier.d.mts.map +1 -1
  4. package/dist/cli-replacement/dependency-install-classifier.mjs +16 -5
  5. package/dist/cli-replacement/dependency-install-classifier.mjs.map +1 -1
  6. package/dist/cli-replacement/dev-s3-restore.test.mjs +73 -14
  7. package/dist/cli-replacement/dev-s3-restore.test.mjs.map +1 -1
  8. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs +4 -0
  9. package/dist/cli-replacement/dev-startup-git-before-dbfs-order.test.mjs.map +1 -1
  10. package/dist/cli-replacement/dev.d.mts.map +1 -1
  11. package/dist/cli-replacement/dev.mjs +130 -12
  12. package/dist/cli-replacement/dev.mjs.map +1 -1
  13. package/dist/cli-replacement/install-packages.npm-registry.test.mjs +153 -0
  14. package/dist/cli-replacement/install-packages.npm-registry.test.mjs.map +1 -1
  15. package/dist/cli-replacement/npm-install-summary.d.mts +30 -0
  16. package/dist/cli-replacement/npm-install-summary.d.mts.map +1 -0
  17. package/dist/cli-replacement/npm-install-summary.mjs +67 -0
  18. package/dist/cli-replacement/npm-install-summary.mjs.map +1 -0
  19. package/dist/cli-replacement/npm-install-summary.test.d.mts +2 -0
  20. package/dist/cli-replacement/npm-install-summary.test.d.mts.map +1 -0
  21. package/dist/cli-replacement/npm-install-summary.test.mjs +70 -0
  22. package/dist/cli-replacement/npm-install-summary.test.mjs.map +1 -0
  23. package/dist/cli-replacement/npm-install-timing.d.mts +16 -0
  24. package/dist/cli-replacement/npm-install-timing.d.mts.map +1 -0
  25. package/dist/cli-replacement/npm-install-timing.mjs +104 -0
  26. package/dist/cli-replacement/npm-install-timing.mjs.map +1 -0
  27. package/dist/cli-replacement/npm-install-timing.test.d.mts +2 -0
  28. package/dist/cli-replacement/npm-install-timing.test.d.mts.map +1 -0
  29. package/dist/cli-replacement/npm-install-timing.test.mjs +151 -0
  30. package/dist/cli-replacement/npm-install-timing.test.mjs.map +1 -0
  31. package/dist/telemetry/logging.d.ts +7 -0
  32. package/dist/telemetry/logging.d.ts.map +1 -1
  33. package/dist/telemetry/logging.js +13 -0
  34. package/dist/telemetry/logging.js.map +1 -1
  35. package/dist/telemetry/logging.test.js +40 -0
  36. package/dist/telemetry/logging.test.js.map +1 -1
  37. package/package.json +6 -6
  38. package/src/cli-replacement/dependency-install-classifier.mts +38 -6
  39. package/src/cli-replacement/dev-s3-restore.test.mts +98 -14
  40. package/src/cli-replacement/dev-startup-git-before-dbfs-order.test.mts +4 -0
  41. package/src/cli-replacement/dev.mts +155 -11
  42. package/src/cli-replacement/install-packages.npm-registry.test.mts +235 -0
  43. package/src/cli-replacement/npm-install-summary.mts +82 -0
  44. package/src/cli-replacement/npm-install-summary.test.mts +94 -0
  45. package/src/cli-replacement/npm-install-timing.mts +121 -0
  46. package/src/cli-replacement/npm-install-timing.test.mts +199 -0
  47. package/src/telemetry/logging.test.ts +55 -0
  48. package/src/telemetry/logging.ts +21 -0
  49. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,94 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { parseNpmInstallSummary } from "./npm-install-summary.mjs";
4
+
5
+ describe("parseNpmInstallSummary", () => {
6
+ it("reads npm's numeric count summary", () => {
7
+ expect(
8
+ parseNpmInstallSummary(
9
+ JSON.stringify({
10
+ added: 8,
11
+ removed: 0,
12
+ changed: 2,
13
+ audited: 432,
14
+ funding: 12,
15
+ }),
16
+ ),
17
+ ).toEqual({ added: 8, removed: 0, changed: 2, audited: 432 });
18
+ });
19
+
20
+ // Some npm versions emit the affected packages as arrays instead of counts.
21
+ it("reads the array form by taking lengths", () => {
22
+ expect(
23
+ parseNpmInstallSummary(
24
+ JSON.stringify({
25
+ added: [{ name: "@dnd-kit/core" }, { name: "@dnd-kit/utilities" }],
26
+ removed: [],
27
+ changed: [{ name: "react" }],
28
+ audited: 432,
29
+ }),
30
+ ),
31
+ ).toEqual({ added: 2, removed: 0, changed: 1, audited: 432 });
32
+ });
33
+
34
+ // `{}` carries none of npm's summary keys, so it is indistinguishable from
35
+ // another tool's JSON output. Treat it as unrecognized: the caller's
36
+ // raw_output fallback keeps the text recoverable instead of logging a
37
+ // confident all-zero install that never happened.
38
+ it("returns null for a JSON object without any npm summary keys", () => {
39
+ expect(parseNpmInstallSummary("{}")).toBeNull();
40
+ // e.g. a different package manager honoring --json with its own shape
41
+ expect(
42
+ parseNpmInstallSummary(
43
+ JSON.stringify({ success: true, warnings: [], elapsed: 3200 }),
44
+ ),
45
+ ).toBeNull();
46
+ });
47
+
48
+ it("tolerates surrounding whitespace and npm's trailing newline", () => {
49
+ expect(parseNpmInstallSummary('\n {"added": 1}\n')).toEqual({
50
+ added: 1,
51
+ removed: 0,
52
+ changed: 0,
53
+ audited: 0,
54
+ });
55
+ });
56
+
57
+ it("returns null for empty output", () => {
58
+ expect(parseNpmInstallSummary("")).toBeNull();
59
+ expect(parseNpmInstallSummary(" \n ")).toBeNull();
60
+ });
61
+
62
+ it("returns null for non-JSON output rather than throwing", () => {
63
+ expect(parseNpmInstallSummary("added 8 packages in 32s")).toBeNull();
64
+ });
65
+
66
+ it("returns null for JSON that is not an object", () => {
67
+ expect(parseNpmInstallSummary("[]")).toBeNull();
68
+ expect(parseNpmInstallSummary('"done"')).toBeNull();
69
+ });
70
+
71
+ it("clamps a negative count so aggregations cannot be skewed", () => {
72
+ expect(parseNpmInstallSummary(JSON.stringify({ added: -3 }))).toEqual({
73
+ added: 0,
74
+ removed: 0,
75
+ changed: 0,
76
+ audited: 0,
77
+ });
78
+ });
79
+
80
+ it("truncates a fractional count to an integer", () => {
81
+ expect(parseNpmInstallSummary(JSON.stringify({ audited: 12.9 }))).toEqual({
82
+ added: 0,
83
+ removed: 0,
84
+ changed: 0,
85
+ audited: 12,
86
+ });
87
+ });
88
+
89
+ it("ignores unexpected value types instead of emitting NaN", () => {
90
+ expect(
91
+ parseNpmInstallSummary(JSON.stringify({ added: "eight", removed: null })),
92
+ ).toEqual({ added: 0, removed: 0, changed: 0, audited: 0 });
93
+ });
94
+ });
@@ -0,0 +1,121 @@
1
+ /**
2
+ * npm's `--json` install summary says how many packages changed, but not where
3
+ * the time went. A real install reported `changed: 5, added: 0` alongside a
4
+ * 27 second duration - the counts cannot explain that. Measuring the same pod
5
+ * by hand showed the time was `reify:unpack` writing about 65,000 files (23.2s
6
+ * of 24.5s), while resolving metadata (`idealTree`) took 230ms. Without that
7
+ * split, a slow install is indistinguishable from a slow registry.
8
+ *
9
+ * npm records the split itself when run with `--timing`. It does NOT go to
10
+ * stdout: npm writes `<logs-dir>/<log id>-timing.json`, shaped
11
+ * `{ metadata, timers, unfinishedTimers }`, where `timers` is a flat object of
12
+ * timer name to milliseconds.
13
+ */
14
+ import nodeFs from "node:fs/promises";
15
+ import path from "node:path";
16
+
17
+ /**
18
+ * The only timers we are willing to emit, paired with their attribute name.
19
+ *
20
+ * This has to be a fixed list rather than a pass-through of `timers`, because
21
+ * npm also records one `reifyNode:node_modules/<package>` timer per package it
22
+ * touched. Emitting those would mean unbounded attribute cardinality and would
23
+ * put dependency names into telemetry.
24
+ *
25
+ * Seconds, to match the `superblocks.npm.install.duration_seconds` attribute
26
+ * these sit next to.
27
+ */
28
+ const TIMER_ATTRIBUTES: ReadonlyArray<readonly [string, string]> = [
29
+ ["idealTree", "superblocks.npm.install.ideal_tree_seconds"],
30
+ ["reify", "superblocks.npm.install.reify_seconds"],
31
+ ["reify:build", "superblocks.npm.install.build_seconds"],
32
+ ["reify:loadTrees", "superblocks.npm.install.load_trees_seconds"],
33
+ ["reify:unpack", "superblocks.npm.install.unpack_seconds"],
34
+ ];
35
+
36
+ const TIMING_FILE_SUFFIX = "-timing.json";
37
+
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+
42
+ /**
43
+ * npm's log ids are fixed-width ISO timestamps (`2026-08-07T12_00_00_000Z`),
44
+ * so sorting the names alphabetically orders them by time. Cheaper and more
45
+ * predictable than stat-ing every file for an mtime.
46
+ */
47
+ async function listTimingFiles(logsDir: string): Promise<string[]> {
48
+ const entries = await nodeFs.readdir(logsDir);
49
+ return entries.filter((name) => name.endsWith(TIMING_FILE_SUFFIX)).sort();
50
+ }
51
+
52
+ /**
53
+ * npm's own `logs-max` pruning covers its debug logs but leaves timing files
54
+ * alone, so they would pile up on the app volume for the life of the pod. We
55
+ * only ever need the one we just read, so drop them all once it is read.
56
+ */
57
+ async function removeTimingFiles(
58
+ logsDir: string,
59
+ names: string[],
60
+ ): Promise<void> {
61
+ await Promise.all(
62
+ names.map((name) =>
63
+ nodeFs.unlink(path.join(logsDir, name)).catch(() => undefined),
64
+ ),
65
+ );
66
+ }
67
+
68
+ function toSeconds(value: unknown): number | undefined {
69
+ if (typeof value !== "number" || !Number.isFinite(value)) {
70
+ return undefined;
71
+ }
72
+ // A negative duration is meaningless and would skew any aggregation built on
73
+ // these attributes.
74
+ return Math.max(0, value) / 1000;
75
+ }
76
+
77
+ /**
78
+ * Read the phase durations npm recorded for the most recent install, then
79
+ * delete the timing files so the logs dir stays bounded.
80
+ *
81
+ * Returns null when there is nothing usable - no timing file, an unreadable or
82
+ * non-JSON file, or a file carrying none of the whitelisted timers. A timer
83
+ * npm did not record is omitted rather than reported as 0, because "npm
84
+ * unpacked nothing" and "npm never got as far as unpacking" are different
85
+ * facts; a recorded 0 IS emitted, since an install that unpacked nothing is
86
+ * exactly the case worth telling apart from a slow one.
87
+ *
88
+ * Never throws: this runs on the success path of a completed install, and no
89
+ * telemetry read may turn that into a failure.
90
+ */
91
+ export async function consumeNpmInstallTimingAttributes(
92
+ logsDir: string,
93
+ ): Promise<Record<string, number> | null> {
94
+ try {
95
+ const names = await listTimingFiles(logsDir);
96
+ const newest = names.at(-1);
97
+ if (newest === undefined) {
98
+ return null;
99
+ }
100
+
101
+ const raw = await nodeFs.readFile(path.join(logsDir, newest), "utf8");
102
+ await removeTimingFiles(logsDir, names);
103
+
104
+ const parsed: unknown = JSON.parse(raw);
105
+ if (!isRecord(parsed) || !isRecord(parsed.timers)) {
106
+ return null;
107
+ }
108
+ const timers = parsed.timers;
109
+
110
+ const attributes: Record<string, number> = {};
111
+ for (const [timer, attribute] of TIMER_ATTRIBUTES) {
112
+ const seconds = toSeconds(timers[timer]);
113
+ if (seconds !== undefined) {
114
+ attributes[attribute] = seconds;
115
+ }
116
+ }
117
+ return Object.keys(attributes).length > 0 ? attributes : null;
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
@@ -0,0 +1,199 @@
1
+ import nodeFs from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
6
+
7
+ import { consumeNpmInstallTimingAttributes } from "./npm-install-timing.mjs";
8
+
9
+ let logsDir: string;
10
+
11
+ beforeEach(async () => {
12
+ logsDir = await nodeFs.mkdtemp(path.join(os.tmpdir(), "npm-timing-"));
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await nodeFs.rm(logsDir, { force: true, recursive: true });
17
+ });
18
+
19
+ /** Write a timing file using npm's own naming (`<log id>-timing.json`). */
20
+ async function writeTimingFile(
21
+ logId: string,
22
+ contents: unknown,
23
+ ): Promise<string> {
24
+ const file = path.join(logsDir, `${logId}-timing.json`);
25
+ await nodeFs.writeFile(
26
+ file,
27
+ typeof contents === "string" ? contents : JSON.stringify(contents),
28
+ );
29
+ return file;
30
+ }
31
+
32
+ async function listTimingFiles(): Promise<string[]> {
33
+ const entries = await nodeFs.readdir(logsDir);
34
+ return entries.filter((name) => name.endsWith("-timing.json"));
35
+ }
36
+
37
+ describe("consumeNpmInstallTimingAttributes", () => {
38
+ it("converts the whitelisted timers from milliseconds to seconds", async () => {
39
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
40
+ metadata: { version: "11.16.0" },
41
+ timers: {
42
+ idealTree: 230,
43
+ reify: 24500,
44
+ "reify:build": 90,
45
+ "reify:loadTrees": 400,
46
+ "reify:unpack": 23200,
47
+ },
48
+ unfinishedTimers: {},
49
+ });
50
+
51
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
52
+ "superblocks.npm.install.build_seconds": 0.09,
53
+ "superblocks.npm.install.ideal_tree_seconds": 0.23,
54
+ "superblocks.npm.install.load_trees_seconds": 0.4,
55
+ "superblocks.npm.install.reify_seconds": 24.5,
56
+ "superblocks.npm.install.unpack_seconds": 23.2,
57
+ });
58
+ });
59
+
60
+ // npm records one `reifyNode:node_modules/<package>` timer per package it
61
+ // touched. Emitting those would be unbounded attribute cardinality and would
62
+ // put dependency names into telemetry, so only the fixed whitelist is ever
63
+ // emitted.
64
+ it("never emits a per-package reifyNode timer", async () => {
65
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
66
+ timers: {
67
+ idealTree: 100,
68
+ "reifyNode:node_modules/@dnd-kit/core": 12,
69
+ "reifyNode:node_modules/lodash": 34,
70
+ "reifyNode:node_modules/react": 56,
71
+ },
72
+ });
73
+
74
+ const attributes = await consumeNpmInstallTimingAttributes(logsDir);
75
+
76
+ expect(attributes).toEqual({
77
+ "superblocks.npm.install.ideal_tree_seconds": 0.1,
78
+ });
79
+ expect(
80
+ Object.keys(attributes ?? {}).some((key) => key.includes("reifyNode")),
81
+ ).toBe(false);
82
+ });
83
+
84
+ // Which phases npm records depends on what the install did, so a missing
85
+ // timer is normal rather than an error.
86
+ it("omits timers npm did not record", async () => {
87
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
88
+ timers: { reify: 500 },
89
+ });
90
+
91
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
92
+ "superblocks.npm.install.reify_seconds": 0.5,
93
+ });
94
+ });
95
+
96
+ // "npm unpacked nothing" is the signal that separates a metadata-only
97
+ // install from one that wrote tens of thousands of files, so a recorded zero
98
+ // is real data and must not be dropped as if it were missing.
99
+ it("emits a recorded zero rather than dropping it", async () => {
100
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
101
+ timers: { "reify:unpack": 0 },
102
+ });
103
+
104
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
105
+ "superblocks.npm.install.unpack_seconds": 0,
106
+ });
107
+ });
108
+
109
+ // npm's log ids are fixed-width ISO timestamps, so the newest file is the
110
+ // last one alphabetically.
111
+ it("reads the newest file when older installs left files behind", async () => {
112
+ await writeTimingFile("2026-08-07T11_00_00_000Z", {
113
+ timers: { reify: 1000 },
114
+ });
115
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
116
+ timers: { reify: 2000 },
117
+ });
118
+
119
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
120
+ "superblocks.npm.install.reify_seconds": 2,
121
+ });
122
+ });
123
+
124
+ // npm's `logs-max` pruning only covers its debug logs, so timing files would
125
+ // pile up on the app volume forever if nothing removed them.
126
+ it("removes the timing files it read so they cannot accumulate", async () => {
127
+ await writeTimingFile("2026-08-07T11_00_00_000Z", {
128
+ timers: { reify: 1000 },
129
+ });
130
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
131
+ timers: { reify: 2000 },
132
+ });
133
+ const debugLog = path.join(logsDir, "2026-08-07T12_00_00_000Z-debug-0.log");
134
+ await nodeFs.writeFile(debugLog, "npm debug output");
135
+
136
+ await consumeNpmInstallTimingAttributes(logsDir);
137
+
138
+ expect(await listTimingFiles()).toEqual([]);
139
+ // npm's debug log is a separate diagnostic that must survive.
140
+ await expect(nodeFs.readFile(debugLog, "utf8")).resolves.toBe(
141
+ "npm debug output",
142
+ );
143
+ });
144
+
145
+ it("returns null when the logs dir has no timing file", async () => {
146
+ await nodeFs.writeFile(
147
+ path.join(logsDir, "2026-08-07T12_00_00_000Z-debug-0.log"),
148
+ "npm debug output",
149
+ );
150
+
151
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toBeNull();
152
+ });
153
+
154
+ it("returns null when the logs dir does not exist", async () => {
155
+ expect(
156
+ await consumeNpmInstallTimingAttributes(path.join(logsDir, "missing")),
157
+ ).toBeNull();
158
+ });
159
+
160
+ it("returns null for a file that is not JSON rather than throwing", async () => {
161
+ await writeTimingFile("2026-08-07T12_00_00_000Z", "not json at all");
162
+
163
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toBeNull();
164
+ });
165
+
166
+ it("returns null when the file carries no timers object", async () => {
167
+ await writeTimingFile("2026-08-07T12_00_00_000Z", { metadata: {} });
168
+
169
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toBeNull();
170
+ });
171
+
172
+ it("returns null when none of the whitelisted timers are present", async () => {
173
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
174
+ timers: { "npm:load": 12, "reifyNode:node_modules/lodash": 34 },
175
+ });
176
+
177
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toBeNull();
178
+ });
179
+
180
+ it("ignores timer values that are not finite numbers", async () => {
181
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
182
+ timers: { idealTree: "230", reify: null, "reify:unpack": 23200 },
183
+ });
184
+
185
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
186
+ "superblocks.npm.install.unpack_seconds": 23.2,
187
+ });
188
+ });
189
+
190
+ it("clamps a negative duration so dashboards cannot be skewed", async () => {
191
+ await writeTimingFile("2026-08-07T12_00_00_000Z", {
192
+ timers: { reify: -500 },
193
+ });
194
+
195
+ expect(await consumeNpmInstallTimingAttributes(logsDir)).toEqual({
196
+ "superblocks.npm.install.reify_seconds": 0,
197
+ });
198
+ });
199
+ });
@@ -156,6 +156,61 @@ describe("telemetry logger error sanitization", () => {
156
156
  });
157
157
  });
158
158
 
159
+ describe("telemetry logger infoStructured", () => {
160
+ beforeEach(() => {
161
+ vi.clearAllMocks();
162
+ });
163
+
164
+ it("emits an INFO record carrying structured attributes (not flattened into the body)", () => {
165
+ const logger = getLogger();
166
+
167
+ logger.infoStructured?.("Package installation completed successfully", {
168
+ "superblocks.npm.install.added": 8,
169
+ "superblocks.npm.install.duration_seconds": 32.5,
170
+ "superblocks.npm.install.outcome": "success",
171
+ });
172
+
173
+ expect(emitMock).toHaveBeenCalledWith(
174
+ expect.objectContaining({
175
+ severityText: "INFO",
176
+ body: "sanitized-message:Package installation completed successfully",
177
+ attributes: {
178
+ "superblocks.npm.install.added": 8,
179
+ "superblocks.npm.install.duration_seconds": 32.5,
180
+ "superblocks.npm.install.outcome": "sanitized-object:success",
181
+ },
182
+ }),
183
+ );
184
+ // Winston gets the body only (structured attributes go to OTel, not also
185
+ // to stdout log shippers as a duplicate).
186
+ expect(winstonLoggerMock.info).toHaveBeenCalledWith(
187
+ "sanitized-message:Package installation completed successfully",
188
+ );
189
+ });
190
+
191
+ it("strips secret-named attribute keys before emitting", () => {
192
+ const logger = getLogger();
193
+
194
+ logger.infoStructured?.("install done", {
195
+ registry: "https://npm.example.com",
196
+ token: "[REDACTED:token]",
197
+ });
198
+
199
+ expect(emitMock).toHaveBeenCalledWith(
200
+ expect.objectContaining({
201
+ attributes: expect.objectContaining({
202
+ registry: "sanitized-object:https://npm.example.com",
203
+ }),
204
+ }),
205
+ );
206
+ expect(emitMock).not.toHaveBeenCalledWith(
207
+ expect.objectContaining({
208
+ attributes: expect.objectContaining({ token: expect.anything() }),
209
+ }),
210
+ );
211
+ });
212
+ });
213
+
159
214
  describe("telemetry logger warnStructured", () => {
160
215
  beforeEach(() => {
161
216
  vi.clearAllMocks();
@@ -79,6 +79,13 @@ export interface Logger {
79
79
  debug: (...messages: unknown[]) => void;
80
80
  info: (...messages: unknown[]) => void;
81
81
  warn: (...messages: unknown[]) => void;
82
+ /**
83
+ * Emit an INFO-level log whose structured attributes are exported as OTel
84
+ * log attributes (Datadog facets) instead of being flattened into the
85
+ * message body. Plain `info()` joins every argument into one body string, so
86
+ * attributes passed to it are never queryable.
87
+ */
88
+ infoStructured?: (message: string, attributes: LogAttributes) => void;
82
89
  /**
83
90
  * Emit a WARN-level log whose structured attributes are exported as OTel log
84
91
  * attributes (Datadog facets) instead of being flattened into the message
@@ -120,6 +127,18 @@ const logger: Logger = Object.freeze({
120
127
  });
121
128
  winstonLogger.warn(body);
122
129
  },
130
+ infoStructured: (message: string, attributes: LogAttributes) => {
131
+ const body = sanitizeLogMessage(message);
132
+ // Same sanitize-then-emit contract as warnStructured below.
133
+ const safeAttributes = sanitizeLogObject(attributes);
134
+ getTracedLogger().emit({
135
+ severityNumber: SeverityNumber.INFO,
136
+ severityText: "INFO",
137
+ body,
138
+ attributes: safeAttributes,
139
+ });
140
+ winstonLogger.info(body);
141
+ },
123
142
  warnStructured: (message: string, attributes: LogAttributes) => {
124
143
  const body = sanitizeLogMessage(message);
125
144
  // sanitizeLogObject strips secret-named keys and redacts secret values,
@@ -174,6 +193,8 @@ export function getLogger(
174
193
  // above, it forwards raw args to the supplied function and does NOT
175
194
  // sanitize or emit OTel attributes. Production callers use the default
176
195
  // logger (and the vite-plugin wrapLogger) which sanitize before emitting.
196
+ infoStructured: (message: string, attributes: LogAttributes) =>
197
+ loggerOverride(message, attributes),
177
198
  warnStructured: (message: string, attributes: LogAttributes) =>
178
199
  loggerOverride(message, attributes),
179
200
  error: loggerOverride as (message: string, meta?: ErrorMeta) => void,