diff --git a/src/index.ts b/src/index.ts index 1161e0b..70772f1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export { recordSpanError, + setSpanAttributesSafe, spanErrorAttributes, withSpan, type WithSpanOptions, diff --git a/src/span.ts b/src/span.ts index 3fd69ff..00a056d 100644 --- a/src/span.ts +++ b/src/span.ts @@ -3,6 +3,7 @@ import { SpanStatusCode, trace, type Attributes, + type AttributeValue, type Span, type SpanOptions, type Tracer, @@ -42,12 +43,51 @@ export function recordSpanError(span: Span, err: unknown): void { span.setAttributes(spanErrorAttributes(err)); } +export function setSpanAttributesSafe( + span: Span, + attributes: Record, +): number { + let count = 0; + + for (const [key, value] of Object.entries(attributes)) { + if (key.length === 0 || !isAttributeValue(value)) continue; + span.setAttribute(key, value); + count += 1; + } + + return count; +} + export function spanErrorAttributes(err: unknown): Attributes { return { "error.type": errorType(err), }; } +function isAttributeValue(value: unknown): value is AttributeValue { + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) { + return true; + } + + if (!Array.isArray(value)) return false; + if (value.length === 0) return true; + + const elementType = typeof value[0]; + if ( + elementType !== "string" && + elementType !== "number" && + elementType !== "boolean" + ) { + return false; + } + + return value.every((item) => typeof item === elementType); +} + function toException(err: unknown): Error { if (err instanceof Error) return err; return new Error(typeof err === "string" ? err : JSON.stringify(err)); diff --git a/test/span.test.ts b/test/span.test.ts index 3c8353c..a45951a 100644 --- a/test/span.test.ts +++ b/test/span.test.ts @@ -1,6 +1,11 @@ import { SpanStatusCode } from "@opentelemetry/api"; import { describe, expect, it, vi } from "vitest"; -import { recordSpanError, spanErrorAttributes, withSpan } from "../src/index.js"; +import { + recordSpanError, + setSpanAttributesSafe, + spanErrorAttributes, + withSpan, +} from "../src/index.js"; describe("span helpers", () => { it("records low-cardinality error.type", () => { @@ -26,6 +31,44 @@ describe("span helpers", () => { expect(span.setAttributes).toHaveBeenCalledWith({ "error.type": "Error" }); }); + it("sets valid span attributes and returns the number set", () => { + const span = { + setAttribute: vi.fn(), + }; + + const count = setSpanAttributesSafe(span as never, { + string: "value", + number: 42, + boolean: true, + strings: ["one", "two"], + numbers: [1, 2], + booleans: [true, false], + }); + + expect(count).toBe(6); + expect(span.setAttribute).toHaveBeenCalledTimes(6); + expect(span.setAttribute).toHaveBeenCalledWith("string", "value"); + expect(span.setAttribute).toHaveBeenCalledWith("numbers", [1, 2]); + }); + + it("drops empty keys, null, undefined, and invalid attribute values", () => { + const span = { + setAttribute: vi.fn(), + }; + + const count = setSpanAttributesSafe(span as never, { + "": "invalid", + null: null, + undefined, + object: { value: "invalid" }, + function: () => "invalid", + mixed: ["one", 2], + }); + + expect(count).toBe(0); + expect(span.setAttribute).not.toHaveBeenCalled(); + }); + it("runs callback using a supplied tracer and ends spans", async () => { const span = { end: vi.fn(),