Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 43 additions & 2 deletions .github/workflows/openai-sdk-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,27 @@ jobs:
pnpm run build:deps
pnpm --filter @traceai/openai run build
pnpm --dir packages/traceai_openai pack --out "$RUNNER_TEMP/openai.tgz"
pnpm --dir packages/fi-core pack --out "$RUNNER_TEMP/core.tgz"
npm install --prefix "$RUNNER_TEMP/consumer" --ignore-scripts --no-audit --no-fund \
"$RUNNER_TEMP/openai.tgz" "openai@${{ matrix.sdk }}" \
@traceai/fi-core@1.1.0 @traceai/fi-semantic-conventions@1.0.0 \
@opentelemetry/sdk-trace-base@2.0.1 @opentelemetry/exporter-trace-otlp-http@0.202.0 \
"$RUNNER_TEMP/core.tgz" @traceai/fi-semantic-conventions@1.0.0 \
@opentelemetry/sdk-trace-base@2.0.1 @opentelemetry/context-async-hooks@2.0.1 \
@opentelemetry/exporter-trace-otlp-http@0.202.0 \
@opentelemetry/exporter-trace-otlp-proto@0.202.0 @opentelemetry/otlp-transformer@0.202.0 \
typescript@5.8.3 @types/node@22.15.29
- name: Install a non-hoisted package consumer
run: |
mkdir -p "$RUNNER_TEMP/strict-consumer"
cat > "$RUNNER_TEMP/strict-consumer/package.json" <<JSON
{"private":true,"dependencies":{"@traceai/openai":"file:$RUNNER_TEMP/openai.tgz","openai":"${{ matrix.sdk }}"},"pnpm":{"overrides":{"@traceai/fi-core":"file:$RUNNER_TEMP/core.tgz"}}}
JSON
pnpm --dir "$RUNNER_TEMP/strict-consumer" install --ignore-scripts \
--config.hoist=false --config.shamefully-hoist=false
- name: Network-isolated non-hoisted imports
run: |
sudo unshare --net -- bash -c 'ip link set lo up; exec "$@"' bash \
env PATH="$PATH" OPENAI_STRICT_CONSUMER_DIR="$RUNNER_TEMP/strict-consumer" \
node --test packages/traceai_openai/test/strictImports.cjs
- name: Network-isolated automatic and manual SDK contracts
env:
OPENAI_CONSUMER_DIR: ${{ runner.temp }}/consumer
Expand All @@ -66,6 +81,26 @@ jobs:
done
done
done
- name: Network-isolated recall regressions
env:
OPENAI_CONSUMER_DIR: ${{ runner.temp }}/consumer
run: |
sudo unshare --net -- bash -c 'ip link set lo up; exec "$@"' bash \
env PATH="$PATH" OPENAI_CONSUMER_DIR="$OPENAI_CONSUMER_DIR" \
node --test packages/traceai_openai/test/recallRegressions.cjs \
packages/traceai_openai/test/privacyContext.cjs \
packages/traceai_openai/test/reviewRegressions.cjs \
packages/traceai_openai/test/reviewRoundTwo.cjs \
packages/traceai_openai/test/reviewRoundThree.cjs \
packages/traceai_openai/test/choiceIndexes.cjs
- name: Typecheck current SDK request contracts
if: matrix.sdk == '7.47.0'
env:
OPENAI_CONSUMER_DIR: ${{ runner.temp }}/consumer
run: |
cp packages/traceai_openai/test/currentSdkShapes.mts "$OPENAI_CONSUMER_DIR/"
"$OPENAI_CONSUMER_DIR/node_modules/.bin/tsc" --noEmit --module nodenext --target es2022 \
"$OPENAI_CONSUMER_DIR/currentSdkShapes.mts"
- name: Network-isolated promise and provider regressions
env:
OPENAI_SDK_PATH: ${{ runner.temp }}/consumer/node_modules/openai
Expand Down Expand Up @@ -97,3 +132,9 @@ jobs:
sudo unshare --net -- bash -c 'ip link set lo up; exec "$@"' bash \
env PATH="$PATH" node packages/traceai_openai/node_modules/jest/bin/jest.js \
--config packages/traceai_openai/jest.config.js --runInBand
sudo unshare --net -- bash -c 'ip link set lo up; exec "$@"' bash \
env PATH="$PATH" node packages/fi-core/node_modules/jest/bin/jest.js \
--config packages/fi-core/jest.config.js --runInBand
sudo unshare --net -- bash -c 'ip link set lo up; exec "$@"' bash \
env PATH="$PATH" FI_CORE_FIXTURE="${{ runner.temp }}/consumer" \
node --test packages/fi-core/test/genai-privacy.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base";
import { SemanticConventions as SC } from "@traceai/fi-semantic-conventions";
import { FITracer } from "../trace/trace-config/FITracer";
import { TraceConfigOptions } from "../trace/trace-config/types";

const cases: [keyof TraceConfigOptions, string[]][] = [
["hideInputs", [SC.PROMPT_TEMPLATE_VARIABLES, `${SC.EMBEDDING_EMBEDDINGS}.0.${SC.EMBEDDING_TEXT}`, `${SC.EMBEDDING_EMBEDDINGS}.12.${SC.EMBEDDING_TEXT}`]],
["hideInputText", [SC.PROMPT_TEMPLATE_VARIABLES, SC.INPUT_VALUE, SC.RAW_INPUT, `${SC.EMBEDDING_EMBEDDINGS}.0.${SC.EMBEDDING_TEXT}`]],
["hideInputImages", [SC.PROMPT_TEMPLATE_VARIABLES, SC.INPUT_VALUE, SC.RAW_INPUT]],
["hideInputMessages", [SC.PROMPT_TEMPLATE_VARIABLES, SC.INPUT_VALUE, SC.RAW_INPUT]],
["hideOutputText", [SC.OUTPUT_VALUE, SC.RAW_OUTPUT]],
["hideOutputMessages", [SC.OUTPUT_VALUE, SC.RAW_OUTPUT]],
["hideEmbeddingVectors", [SC.OUTPUT_VALUE, SC.RAW_OUTPUT, `${SC.EMBEDDING_EMBEDDINGS}.0.${SC.EMBEDDING_VECTOR}`]],
];

describe("privacy covers duplicate payload attributes", () => {
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)] });
afterEach(() => exporter.reset());
afterAll(() => provider.shutdown());

for (const [flag, keys] of cases) {
for (const placement of ["creation", "setter", "link"] as const) {
it(`${flag} protects ${placement} attributes without removing safe metadata`, () => {
const attributes = Object.fromEntries(keys.map(key => [key, "SYNTHETIC-PRIVATE-PAYLOAD"]));
const tracer = new FITracer({ tracer: provider.getTracer("privacy"), traceConfig: { [flag]: true } });
const span = tracer.startSpan("test", {
attributes: { [SC.LLM_MODEL_NAME]: "synthetic-model", ...(placement === "creation" ? attributes : {}) },
links: placement === "link" ? [{ context: { traceId: "1".repeat(32), spanId: "2".repeat(16), traceFlags: 1 }, attributes }] : [],
});
if (placement === "setter") {
span.setAttributes(attributes);
for (const [key, value] of Object.entries(attributes)) span.setAttribute(key, value);
}
span.end();
const finished = exporter.getFinishedSpans()[0];
expect(JSON.stringify({ attributes: finished.attributes, links: finished.links })).not.toContain("SYNTHETIC-PRIVATE-PAYLOAD");
expect(finished.attributes[SC.LLM_MODEL_NAME]).toBe("synthetic-model");
});
}
}

it("preserves all payloads with hiding disabled", () => {
const attributes = Object.fromEntries(cases.flatMap(([, keys]) => keys.map(key => [key, "SYNTHETIC-PUBLIC-PAYLOAD"])));
const tracer = new FITracer({ tracer: provider.getTracer("privacy"), traceConfig: {} });
const span = tracer.startSpan("test", { attributes });
span.end();
expect(exporter.getFinishedSpans()[0].attributes).toEqual(attributes);
});
});
114 changes: 26 additions & 88 deletions typescript/packages/fi-core/src/trace/trace-config/maskingRules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,85 +4,23 @@ import { SemanticConventions } from "@traceai/fi-semantic-conventions";
import { MaskingRule, MaskingRuleArgs, TraceConfig } from "./types";
import { redactPiiInValue } from "./piiRedaction";
import { isErrorDetailAttribute } from "./errorDetails";
import { redactMessages } from "./messageRedaction";

/**
* Redacts content within a JSON blob of messages.
* Parses the JSON string, replaces all "content" fields with REDACTED_VALUE,
* and returns the modified JSON string.
*/
function redactContentInJsonBlob(value: AttributeValue): AttributeValue | undefined {
if (typeof value !== "string") return value;
try {
const messages = JSON.parse(value);
if (!Array.isArray(messages)) return REDACTED_VALUE;
const redacted = messages.map((msg: Record<string, unknown>) => {
const copy = { ...msg };
if (copy.content !== undefined) {
copy.content = REDACTED_VALUE;
}
return copy;
});
return JSON.stringify(redacted);
} catch {
return REDACTED_VALUE;
}
}

/**
* Redacts images within a JSON blob of messages.
* Removes image_url fields from content arrays within messages.
*/
function redactImagesInJsonBlob(value: AttributeValue): AttributeValue | undefined {
if (typeof value !== "string") return value;
try {
const messages = JSON.parse(value);
if (!Array.isArray(messages)) return value;
const redacted = messages.map((msg: Record<string, unknown>) => {
const copy = { ...msg };
if (Array.isArray(copy.content)) {
copy.content = (copy.content as Record<string, unknown>[]).filter(
(part) => part.type !== "image_url" && part.type !== "input_image" && part.type !== "image"
);
}
return copy;
});
return JSON.stringify(redacted);
} catch {
return value;
}
}

/**
* Masks input text in LLM input messages (JSON blob format).
* Redacts "content" fields within the gen_ai.input.messages JSON blob.
*/
const maskInputTextRule: MaskingRule = {
// Apply text and image settings together: the masking rule list stops at its
// first match, so independent rules would let image hiding bypass text hiding.
const maskInputContentRule: MaskingRule = {
condition: ({ config, key }) =>
config.hideInputText &&
(config.hideInputText || config.hideInputImages) &&
key === SemanticConventions.LLM_INPUT_MESSAGES,
action: ({ value }: MaskingRuleArgs) => value != null ? redactContentInJsonBlob(value) : undefined,
action: ({ config, value }) => value != null
? redactMessages({ value, hideText: config.hideInputText, hideImages: config.hideInputImages }) : undefined,
};

/**
* Masks output text in LLM output messages (JSON blob format).
* Redacts "content" fields within the gen_ai.output.messages JSON blob.
*/
const maskOutputTextRule: MaskingRule = {
condition: ({ config, key }) =>
config.hideOutputText &&
key === SemanticConventions.LLM_OUTPUT_MESSAGES,
action: ({ value }: MaskingRuleArgs) => value != null ? redactContentInJsonBlob(value) : undefined,
};

/**
* Masks input images in LLM input messages (JSON blob format).
* Removes image content parts from the gen_ai.input.messages JSON blob.
*/
const maskInputImagesRule: MaskingRule = {
condition: ({ config, key }) =>
config.hideInputImages &&
key === SemanticConventions.LLM_INPUT_MESSAGES,
action: ({ value }: MaskingRuleArgs) => value != null ? redactImagesInJsonBlob(value) : undefined,
config.hideOutputText && key === SemanticConventions.LLM_OUTPUT_MESSAGES,
action: ({ value }) => value != null
? redactMessages({ value, hideText: true, hideImages: false }) : undefined,
};

/**
Expand All @@ -109,20 +47,25 @@ const maskingRules: MaskingRule[] = [
action: () => undefined,
},
{
// Raw/value payloads are opaque duplicates. Do not attempt provider-specific
// JSON parsing here: granular hiding must also cover every copied payload.
condition: ({ config, key }) =>
config.hideInputs && key === SemanticConventions.INPUT_VALUE,
(config.hideInputs || config.hideInputText || config.hideInputImages || config.hideInputMessages) &&
(key === SemanticConventions.INPUT_VALUE || key === SemanticConventions.RAW_INPUT),
action: () => REDACTED_VALUE as AttributeValue,
},
{
// The raw request body (e.g. the full OpenAI request, messages included), like input.value
condition: ({ config, key }) =>
config.hideInputs && key === SemanticConventions.RAW_INPUT,
(config.hideInputs || config.hideInputText) &&
key.startsWith(`${SemanticConventions.EMBEDDING_EMBEDDINGS}.`) &&
key.endsWith(`.${SemanticConventions.EMBEDDING_TEXT}`),
action: () => REDACTED_VALUE as AttributeValue,
},
{
// The values filled into a prompt template are the template's inputs
// Prompt substitution values are opaque input payloads, including images.
condition: ({ config, key }) =>
config.hideInputs && key === SemanticConventions.PROMPT_TEMPLATE_VARIABLES,
(config.hideInputs || config.hideInputText || config.hideInputImages || config.hideInputMessages) &&
key === SemanticConventions.PROMPT_TEMPLATE_VARIABLES,
action: () => REDACTED_VALUE as AttributeValue,
},
{
Expand All @@ -132,13 +75,8 @@ const maskingRules: MaskingRule[] = [
},
{
condition: ({ config, key }) =>
config.hideOutputs && key === SemanticConventions.OUTPUT_VALUE,
action: () => REDACTED_VALUE as AttributeValue,
},
{
// The raw response body, like output.value
condition: ({ config, key }) =>
config.hideOutputs && key === SemanticConventions.RAW_OUTPUT,
(config.hideOutputs || config.hideOutputText || config.hideOutputMessages || config.hideEmbeddingVectors) &&
(key === SemanticConventions.OUTPUT_VALUE || key === SemanticConventions.RAW_OUTPUT),
action: () => REDACTED_VALUE as AttributeValue,
},
{
Expand All @@ -158,8 +96,7 @@ const maskingRules: MaskingRule[] = [
key === SemanticConventions.LLM_OUTPUT_MESSAGES,
action: () => undefined,
},
maskInputImagesRule,
maskInputTextRule,
maskInputContentRule,
maskOutputTextRule,
maskEmbeddingVectorsRule,
];
Expand All @@ -178,10 +115,11 @@ export function mask({
}: MaskingRuleArgs): AttributeValue | undefined {
for (const rule of maskingRules) {
if (rule.condition({ config, key, value })) {
return rule.action({ config, key, value });
value = rule.action({ config, key, value });
break;
}
}
// No key-based rule matched — apply PII redaction if enabled
// Apply PII redaction to retained text even when a content rule matched.
if (config.piiRedaction && value != null) {
return redactPiiInValue(value);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { AttributeValue } from "@opentelemetry/api";
import { REDACTED_VALUE } from "./constants";

const MESSAGE_METADATA = ["role", "name", "id", "tool_call_id", "finish_reason"];
const PART_METADATA = ["type", "id", "name", "modality", "mime_type"];
const IMAGE_TYPES = ["image", "image_url", "input_image"];
const TEXT_TYPES = ["text", "input_text", "output_text", "reasoning", "compaction"];
const MEDIA_TYPES = ["blob", "file", "uri"];

type JsonObject = Record<string, unknown>;

function isObject(value: unknown): value is JsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}

// Only scalar metadata is safe to retain. Unknown extension fields and structured
// payloads (including tool arguments/results) may contain text or images.
function redactFields(value: JsonObject, keep: readonly string[]): JsonObject {
return Object.fromEntries(Object.entries(value).map(([key, field]) => [
key,
keep.includes(key) && (field === null || typeof field !== "object")
? field : REDACTED_VALUE,
]));
}

function isImage(part: JsonObject): boolean {
return (typeof part.type === "string" && IMAGE_TYPES.includes(part.type)) || part.modality === "image" ||
(typeof part.mime_type === "string" && /^image\//i.test(part.mime_type));
}

function redactParts(parts: unknown, hideText: boolean, hideImages: boolean): unknown {
if (!Array.isArray(parts)) return REDACTED_VALUE;
return parts
.filter(part => !(hideImages && isObject(part) && isImage(part)))
.map(part => {
if (!isObject(part)) return REDACTED_VALUE;
const type = typeof part.type === "string" ? part.type : "";
if (!hideText && TEXT_TYPES.includes(type)) {
return redactFields(part, [...PART_METADATA, "content", "text"]);
}
// Image-only hiding retains explicitly non-image media. Missing/unknown
// modality fails closed rather than guessing what opaque data contains.
if (!hideText && MEDIA_TYPES.includes(type) &&
((typeof part.modality === "string" && ["audio", "video", "document"].includes(part.modality)) ||
(typeof part.mime_type === "string" && /^(audio|video|text)\//i.test(part.mime_type)))) {
return redactFields(part, [...PART_METADATA, "content", "uri", "file_id"]);
}
return redactFields(part, PART_METADATA);
});
}

/**
* Mask both legacy content messages and native OTel GenAI role/parts messages.
* Text hiding conservatively redacts all payloads, including opaque tool/media
* data. Image-only hiding retains known text/non-image media. Unknown shapes
* fail closed; no arbitrary payload is recursively inspected or copied through.
*/
export function redactMessages({ value, hideText, hideImages }: {
value: AttributeValue;
hideText: boolean;
hideImages: boolean;
}): AttributeValue {
if (typeof value !== "string") return REDACTED_VALUE;
try {
const messages: unknown = JSON.parse(value);
if (!Array.isArray(messages) || !messages.every(isObject)) return REDACTED_VALUE;
return JSON.stringify(messages.map(message => {
const redacted = redactFields(message, MESSAGE_METADATA);
if ("content" in message) {
// Preserve the legacy contract: text hiding redacts the entire content,
// including a multimodal array, not just string content.
redacted.content = hideText ? REDACTED_VALUE :
typeof message.content === "string" ? message.content :
redactParts(message.content, hideText, hideImages);
}
if ("parts" in message) {
redacted.parts = redactParts(message.parts, hideText, hideImages);
}
return redacted;
}));
} catch {
return REDACTED_VALUE;
}
}
Loading
Loading