@langfuse/otel 4.0.0-alpha.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/LICENSE +21 -0
- package/dist/hash.d.ts +4 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +42 -0
- package/dist/index.cjs +477 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +75 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.mjs +465 -0
- package/dist/index.mjs.map +1 -0
- package/dist/media.d.ts +30 -0
- package/dist/media.d.ts.map +1 -0
- package/dist/media.js +91 -0
- package/dist/span-processor.d.ts +44 -0
- package/dist/span-processor.d.ts.map +1 -0
- package/dist/span-processor.js +293 -0
- package/package.json +42 -0
package/dist/media.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { getGlobalLogger } from "@langfuse/core";
|
|
2
|
+
import { getSha256HashFromBytes, isCryptoAvailable } from "./hash.js";
|
|
3
|
+
/**
|
|
4
|
+
* A class for wrapping media objects for upload to Langfuse.
|
|
5
|
+
*
|
|
6
|
+
* This class handles the preparation and formatting of media content for Langfuse,
|
|
7
|
+
* supporting both base64 data URIs and raw content bytes.
|
|
8
|
+
*/
|
|
9
|
+
class LangfuseMedia {
|
|
10
|
+
constructor(params) {
|
|
11
|
+
const { source } = params;
|
|
12
|
+
this._source = source;
|
|
13
|
+
if (source === "base64_data_uri") {
|
|
14
|
+
const [contentBytesParsed, contentTypeParsed] = this.parseBase64DataUri(params.base64DataUri);
|
|
15
|
+
this._contentBytes = contentBytesParsed;
|
|
16
|
+
this._contentType = contentTypeParsed;
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
this._contentBytes = params.contentBytes;
|
|
20
|
+
this._contentType = params.contentType;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
parseBase64DataUri(data) {
|
|
24
|
+
try {
|
|
25
|
+
if (!data || typeof data !== "string") {
|
|
26
|
+
throw new Error("Data URI is not a string");
|
|
27
|
+
}
|
|
28
|
+
if (!data.startsWith("data:")) {
|
|
29
|
+
throw new Error("Data URI does not start with 'data:'");
|
|
30
|
+
}
|
|
31
|
+
const [header, actualData] = data.slice(5).split(",", 2);
|
|
32
|
+
if (!header || !actualData) {
|
|
33
|
+
throw new Error("Invalid URI");
|
|
34
|
+
}
|
|
35
|
+
const headerParts = header.split(";");
|
|
36
|
+
if (!headerParts.includes("base64")) {
|
|
37
|
+
throw new Error("Data is not base64 encoded");
|
|
38
|
+
}
|
|
39
|
+
const contentType = headerParts[0];
|
|
40
|
+
if (!contentType) {
|
|
41
|
+
throw new Error("Content type is empty");
|
|
42
|
+
}
|
|
43
|
+
return [
|
|
44
|
+
Buffer.from(actualData, "base64"),
|
|
45
|
+
contentType,
|
|
46
|
+
];
|
|
47
|
+
}
|
|
48
|
+
catch (error) {
|
|
49
|
+
getGlobalLogger().error("Error parsing base64 data URI", error);
|
|
50
|
+
return [undefined, undefined];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
get id() {
|
|
54
|
+
if (!this.contentSha256Hash)
|
|
55
|
+
return null;
|
|
56
|
+
const urlSafeContentHash = this.contentSha256Hash
|
|
57
|
+
.replaceAll("+", "-")
|
|
58
|
+
.replaceAll("/", "_");
|
|
59
|
+
return urlSafeContentHash.slice(0, 22);
|
|
60
|
+
}
|
|
61
|
+
get contentLength() {
|
|
62
|
+
var _a;
|
|
63
|
+
return (_a = this._contentBytes) === null || _a === void 0 ? void 0 : _a.length;
|
|
64
|
+
}
|
|
65
|
+
get contentSha256Hash() {
|
|
66
|
+
if (!this._contentBytes || !isCryptoAvailable) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
return getSha256HashFromBytes(this._contentBytes);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
getGlobalLogger().warn("[Langfuse] Failed to generate SHA-256 hash for media content:", error);
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
get tag() {
|
|
78
|
+
if (!this._contentType || !this._source || !this.id)
|
|
79
|
+
return null;
|
|
80
|
+
return `@@@langfuseMedia:type=${this._contentType}|id=${this.id}|source=${this._source}@@@`;
|
|
81
|
+
}
|
|
82
|
+
get base64DataUri() {
|
|
83
|
+
if (!this._contentBytes)
|
|
84
|
+
return null;
|
|
85
|
+
return `data:${this._contentType};base64,${Buffer.from(this._contentBytes).toString("base64")}`;
|
|
86
|
+
}
|
|
87
|
+
toJSON() {
|
|
88
|
+
return this.base64DataUri;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
export { LangfuseMedia };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Span, BatchSpanProcessor, SpanExporter, ReadableSpan } from "@opentelemetry/sdk-trace-base";
|
|
2
|
+
export type MaskFunction = (params: {
|
|
3
|
+
data: any;
|
|
4
|
+
}) => any;
|
|
5
|
+
export type ShouldExportSpan = (params: {
|
|
6
|
+
otelSpan: ReadableSpan;
|
|
7
|
+
}) => boolean;
|
|
8
|
+
export interface LangfuseSpanProcessorParams {
|
|
9
|
+
exporter?: SpanExporter;
|
|
10
|
+
publicKey?: string;
|
|
11
|
+
secretKey?: string;
|
|
12
|
+
baseUrl?: string;
|
|
13
|
+
flushAt?: number;
|
|
14
|
+
flushInterval?: number;
|
|
15
|
+
mask?: MaskFunction;
|
|
16
|
+
shouldExportSpan?: ShouldExportSpan;
|
|
17
|
+
environment?: string;
|
|
18
|
+
release?: string;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
additionalHeaders?: Record<string, string>;
|
|
21
|
+
}
|
|
22
|
+
export declare class LangfuseSpanProcessor extends BatchSpanProcessor {
|
|
23
|
+
private pendingMediaUploads;
|
|
24
|
+
private publicKey?;
|
|
25
|
+
private baseUrl?;
|
|
26
|
+
private environment?;
|
|
27
|
+
private release?;
|
|
28
|
+
private mask?;
|
|
29
|
+
private shouldExportSpan?;
|
|
30
|
+
private apiClient;
|
|
31
|
+
constructor(params: LangfuseSpanProcessorParams);
|
|
32
|
+
private get logger();
|
|
33
|
+
onStart(span: Span, parentContext: any): void;
|
|
34
|
+
onEnd(span: ReadableSpan): void;
|
|
35
|
+
private flush;
|
|
36
|
+
forceFlush(): Promise<void>;
|
|
37
|
+
shutdown(): Promise<void>;
|
|
38
|
+
private handleMediaInPlace;
|
|
39
|
+
private applyMaskInPlace;
|
|
40
|
+
private applyMask;
|
|
41
|
+
private processMediaItem;
|
|
42
|
+
private uploadMediaWithBackoff;
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=span-processor.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"span-processor.d.ts","sourceRoot":"","sources":["../src/span-processor.ts"],"names":[],"mappings":"AAWA,OAAO,EACL,IAAI,EACJ,kBAAkB,EAClB,YAAY,EACZ,YAAY,EACb,MAAM,+BAA+B,CAAC;AAKvC,MAAM,MAAM,YAAY,GAAG,CAAC,MAAM,EAAE;IAAE,IAAI,EAAE,GAAG,CAAA;CAAE,KAAK,GAAG,CAAC;AAC1D,MAAM,MAAM,gBAAgB,GAAG,CAAC,MAAM,EAAE;IAAE,QAAQ,EAAE,YAAY,CAAA;CAAE,KAAK,OAAO,CAAC;AAE/E,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,EAAE,YAAY,CAAC;IACxB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,IAAI,CAAC,EAAE,YAAY,CAAC;IACpB,gBAAgB,CAAC,EAAE,gBAAgB,CAAC;IACpC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,iBAAiB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC5C;AAED,qBAAa,qBAAsB,SAAQ,kBAAkB;IAC3D,OAAO,CAAC,mBAAmB,CAAoC;IAE/D,OAAO,CAAC,SAAS,CAAC,CAAS;IAC3B,OAAO,CAAC,OAAO,CAAC,CAAS;IACzB,OAAO,CAAC,WAAW,CAAC,CAAS;IAC7B,OAAO,CAAC,OAAO,CAAC,CAAS;IACzB,OAAO,CAAC,IAAI,CAAC,CAAe;IAC5B,OAAO,CAAC,gBAAgB,CAAC,CAAmB;IAC5C,OAAO,CAAC,SAAS,CAAoB;gBAEzB,MAAM,EAAE,2BAA2B;IAyF/C,OAAO,KAAK,MAAM,GAEjB;IAEM,OAAO,CAAC,IAAI,EAAE,IAAI,EAAE,aAAa,EAAE,GAAG,GAAG,IAAI;IAS7C,KAAK,CAAC,IAAI,EAAE,YAAY,GAAG,IAAI;YAyCxB,KAAK;IAQN,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAM3B,QAAQ,IAAI,OAAO,CAAC,IAAI,CAAC;IAMtC,OAAO,CAAC,kBAAkB;IAsF1B,OAAO,CAAC,gBAAgB;IAmBxB,OAAO,CAAC,SAAS;YAcH,gBAAgB;YA4EhB,sBAAsB;CAkDrC"}
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { getGlobalLogger, generateUUID, LangfuseAPIClient, LANGFUSE_SDK_VERSION, LangfuseOtelSpanAttributes, getEnv, } from "@langfuse/core";
|
|
2
|
+
import { hrTimeToMilliseconds } from "@opentelemetry/core";
|
|
3
|
+
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http";
|
|
4
|
+
import { BatchSpanProcessor, } from "@opentelemetry/sdk-trace-base";
|
|
5
|
+
import { isCryptoAvailable } from "./hash.js";
|
|
6
|
+
import { LangfuseMedia } from "./media.js";
|
|
7
|
+
export class LangfuseSpanProcessor extends BatchSpanProcessor {
|
|
8
|
+
constructor(params) {
|
|
9
|
+
var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m;
|
|
10
|
+
const logger = getGlobalLogger();
|
|
11
|
+
const publicKey = (_a = params.publicKey) !== null && _a !== void 0 ? _a : getEnv("LANGFUSE_PUBLIC_KEY");
|
|
12
|
+
const secretKey = (_b = params.secretKey) !== null && _b !== void 0 ? _b : getEnv("LANGFUSE_SECRET_KEY");
|
|
13
|
+
const baseUrl = (_e = (_d = (_c = params.baseUrl) !== null && _c !== void 0 ? _c : getEnv("LANGFUSE_BASE_URL")) !== null && _d !== void 0 ? _d : getEnv("LANGFUSE_BASEURL")) !== null && _e !== void 0 ? _e : "https://cloud.langfuse.com";
|
|
14
|
+
if (!params.exporter && !publicKey) {
|
|
15
|
+
logger.warn("No exporter configured and no public key provided in constructor or as LANGFUSE_PUBLIC_KEY env var. Span exports will fail.");
|
|
16
|
+
}
|
|
17
|
+
if (!params.exporter && !secretKey) {
|
|
18
|
+
logger.warn("No exporter configured and no secret key provided in constructor or as LANGFUSE_SECRET_KEY env var. Span exports will fail.");
|
|
19
|
+
}
|
|
20
|
+
const flushAt = (_f = params.flushAt) !== null && _f !== void 0 ? _f : getEnv("LANGFUSE_FLUSH_AT");
|
|
21
|
+
const flushIntervalSeconds = (_g = params.flushInterval) !== null && _g !== void 0 ? _g : getEnv("LANGFUSE_FLUSH_INTERVAL");
|
|
22
|
+
const authHeaderValue = Buffer.from(`${publicKey}:${secretKey}`).toString("base64");
|
|
23
|
+
const timeoutSeconds = (_h = params.timeout) !== null && _h !== void 0 ? _h : Number((_j = getEnv("LANGFUSE_TIMEOUT")) !== null && _j !== void 0 ? _j : 5);
|
|
24
|
+
const exporter = (_k = params.exporter) !== null && _k !== void 0 ? _k : new OTLPTraceExporter({
|
|
25
|
+
url: `${baseUrl}/api/public/otel/v1/traces`,
|
|
26
|
+
headers: {
|
|
27
|
+
Authorization: `Basic ${authHeaderValue}`,
|
|
28
|
+
x_langfuse_sdk_name: "javascript",
|
|
29
|
+
x_langfuse_sdk_version: LANGFUSE_SDK_VERSION,
|
|
30
|
+
x_langfuse_public_key: publicKey !== null && publicKey !== void 0 ? publicKey : "<missing>",
|
|
31
|
+
...params.additionalHeaders,
|
|
32
|
+
},
|
|
33
|
+
timeoutMillis: timeoutSeconds * 1000,
|
|
34
|
+
});
|
|
35
|
+
super(exporter, {
|
|
36
|
+
maxExportBatchSize: flushAt ? Number(flushAt) : undefined,
|
|
37
|
+
scheduledDelayMillis: flushIntervalSeconds
|
|
38
|
+
? Number(flushIntervalSeconds) * 1000
|
|
39
|
+
: undefined,
|
|
40
|
+
});
|
|
41
|
+
this.pendingMediaUploads = {};
|
|
42
|
+
this.publicKey = publicKey;
|
|
43
|
+
this.baseUrl = baseUrl;
|
|
44
|
+
this.environment =
|
|
45
|
+
(_l = params.environment) !== null && _l !== void 0 ? _l : getEnv("LANGFUSE_TRACING_ENVIRONMENT");
|
|
46
|
+
this.release = (_m = params.release) !== null && _m !== void 0 ? _m : getEnv("LANGFUSE_RELEASE");
|
|
47
|
+
this.mask = params.mask;
|
|
48
|
+
this.shouldExportSpan = params.shouldExportSpan;
|
|
49
|
+
this.apiClient = new LangfuseAPIClient({
|
|
50
|
+
baseUrl: this.baseUrl,
|
|
51
|
+
username: this.publicKey,
|
|
52
|
+
password: secretKey,
|
|
53
|
+
xLangfusePublicKey: this.publicKey,
|
|
54
|
+
xLangfuseSdkVersion: LANGFUSE_SDK_VERSION,
|
|
55
|
+
xLangfuseSdkName: "javascript",
|
|
56
|
+
environment: "", // noop as baseUrl is set
|
|
57
|
+
headers: params.additionalHeaders,
|
|
58
|
+
});
|
|
59
|
+
logger.debug("Initialized LangfuseSpanProcessor with params:", {
|
|
60
|
+
publicKey,
|
|
61
|
+
baseUrl,
|
|
62
|
+
environment: this.environment,
|
|
63
|
+
release: this.release,
|
|
64
|
+
timeoutSeconds,
|
|
65
|
+
flushAt,
|
|
66
|
+
flushIntervalSeconds,
|
|
67
|
+
});
|
|
68
|
+
// Warn if crypto is not available
|
|
69
|
+
if (!isCryptoAvailable) {
|
|
70
|
+
logger.warn("[Langfuse] Crypto module not available in this runtime. Media upload functionality will be disabled. " +
|
|
71
|
+
"Spans will still be processed normally, but any media content in base64 data URIs will not be uploaded to Langfuse.");
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
get logger() {
|
|
75
|
+
return getGlobalLogger();
|
|
76
|
+
}
|
|
77
|
+
onStart(span, parentContext) {
|
|
78
|
+
span.setAttributes({
|
|
79
|
+
[LangfuseOtelSpanAttributes.ENVIRONMENT]: this.environment,
|
|
80
|
+
[LangfuseOtelSpanAttributes.RELEASE]: this.release,
|
|
81
|
+
});
|
|
82
|
+
return super.onStart(span, parentContext);
|
|
83
|
+
}
|
|
84
|
+
onEnd(span) {
|
|
85
|
+
var _a, _b;
|
|
86
|
+
if (this.shouldExportSpan) {
|
|
87
|
+
try {
|
|
88
|
+
if (this.shouldExportSpan({ otelSpan: span }) === false)
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
this.logger.error("ShouldExportSpan failed with error. Excluding span. Error: ", err);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
this.applyMaskInPlace(span);
|
|
97
|
+
this.handleMediaInPlace(span);
|
|
98
|
+
this.logger.debug(`Processed span:\n${JSON.stringify({
|
|
99
|
+
name: span.name,
|
|
100
|
+
traceId: span.spanContext().traceId,
|
|
101
|
+
spanId: span.spanContext().spanId,
|
|
102
|
+
parentSpanId: (_b = (_a = span.parentSpanContext) === null || _a === void 0 ? void 0 : _a.spanId) !== null && _b !== void 0 ? _b : null,
|
|
103
|
+
attributes: span.attributes,
|
|
104
|
+
startTime: new Date(hrTimeToMilliseconds(span.startTime)),
|
|
105
|
+
endTime: new Date(hrTimeToMilliseconds(span.endTime)),
|
|
106
|
+
durationMs: hrTimeToMilliseconds(span.duration),
|
|
107
|
+
kind: span.kind,
|
|
108
|
+
status: span.status,
|
|
109
|
+
resource: span.resource.attributes,
|
|
110
|
+
instrumentationScope: span.instrumentationScope,
|
|
111
|
+
}, null, 2)}`);
|
|
112
|
+
super.onEnd(span);
|
|
113
|
+
}
|
|
114
|
+
async flush() {
|
|
115
|
+
await Promise.all(Object.values(this.pendingMediaUploads)).catch((e) => {
|
|
116
|
+
this.logger.error(e instanceof Error ? e.message : "Unhandled media upload error");
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async forceFlush() {
|
|
120
|
+
await this.flush();
|
|
121
|
+
return super.forceFlush();
|
|
122
|
+
}
|
|
123
|
+
async shutdown() {
|
|
124
|
+
await this.flush();
|
|
125
|
+
return super.shutdown();
|
|
126
|
+
}
|
|
127
|
+
handleMediaInPlace(span) {
|
|
128
|
+
var _a;
|
|
129
|
+
// Skip media handling if crypto is not available
|
|
130
|
+
if (!isCryptoAvailable) {
|
|
131
|
+
this.logger.debug("[Langfuse] Crypto not available, skipping media processing");
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const mediaAttributes = [
|
|
135
|
+
LangfuseOtelSpanAttributes.OBSERVATION_INPUT,
|
|
136
|
+
LangfuseOtelSpanAttributes.TRACE_INPUT,
|
|
137
|
+
LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT,
|
|
138
|
+
LangfuseOtelSpanAttributes.TRACE_OUTPUT,
|
|
139
|
+
LangfuseOtelSpanAttributes.OBSERVATION_METADATA,
|
|
140
|
+
LangfuseOtelSpanAttributes.TRACE_METADATA,
|
|
141
|
+
];
|
|
142
|
+
for (const mediaAttribute of mediaAttributes) {
|
|
143
|
+
const mediaRelevantAttributeKeys = Object.keys(span.attributes).filter((attributeName) => attributeName.startsWith(mediaAttribute));
|
|
144
|
+
for (const key of mediaRelevantAttributeKeys) {
|
|
145
|
+
const value = span.attributes[key];
|
|
146
|
+
if (typeof value !== "string") {
|
|
147
|
+
this.logger.warn(`Span attribute ${mediaAttribute} is not a stringified object. Skipping media handling.`);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
// Find media base64 data URI
|
|
151
|
+
let mediaReplacedValue = value;
|
|
152
|
+
const regex = /data:[^;]+;base64,[A-Za-z0-9+/]+=*/g;
|
|
153
|
+
const foundMedia = [...new Set((_a = value.match(regex)) !== null && _a !== void 0 ? _a : [])];
|
|
154
|
+
if (foundMedia.length === 0)
|
|
155
|
+
continue;
|
|
156
|
+
for (const mediaDataUri of foundMedia) {
|
|
157
|
+
// For each media, create media tag and initiate upload
|
|
158
|
+
const media = new LangfuseMedia({
|
|
159
|
+
base64DataUri: mediaDataUri,
|
|
160
|
+
source: "base64_data_uri",
|
|
161
|
+
});
|
|
162
|
+
if (!media.tag) {
|
|
163
|
+
this.logger.warn("Failed to create Langfuse media tag. Skipping media item.");
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
const uploadPromise = this.processMediaItem({
|
|
167
|
+
media,
|
|
168
|
+
traceId: span.spanContext().traceId,
|
|
169
|
+
observationId: span.spanContext().spanId,
|
|
170
|
+
field: mediaAttribute.includes("input")
|
|
171
|
+
? "input"
|
|
172
|
+
: mediaAttribute.includes("output")
|
|
173
|
+
? "output"
|
|
174
|
+
: "metadata", // todo: make more robust
|
|
175
|
+
});
|
|
176
|
+
const promiseId = generateUUID();
|
|
177
|
+
this.pendingMediaUploads[promiseId] = uploadPromise;
|
|
178
|
+
uploadPromise.finally(() => {
|
|
179
|
+
delete this.pendingMediaUploads[promiseId];
|
|
180
|
+
});
|
|
181
|
+
// Replace original attribute with media escaped attribute
|
|
182
|
+
mediaReplacedValue = mediaReplacedValue.replaceAll(mediaDataUri, media.tag);
|
|
183
|
+
}
|
|
184
|
+
span.attributes[key] = mediaReplacedValue;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
applyMaskInPlace(span) {
|
|
189
|
+
const maskCandidates = [
|
|
190
|
+
LangfuseOtelSpanAttributes.OBSERVATION_INPUT,
|
|
191
|
+
LangfuseOtelSpanAttributes.TRACE_INPUT,
|
|
192
|
+
LangfuseOtelSpanAttributes.OBSERVATION_OUTPUT,
|
|
193
|
+
LangfuseOtelSpanAttributes.TRACE_OUTPUT,
|
|
194
|
+
LangfuseOtelSpanAttributes.OBSERVATION_METADATA,
|
|
195
|
+
LangfuseOtelSpanAttributes.TRACE_METADATA,
|
|
196
|
+
];
|
|
197
|
+
for (const maskCandidate of maskCandidates) {
|
|
198
|
+
if (maskCandidate in span.attributes) {
|
|
199
|
+
span.attributes[maskCandidate] = this.applyMask(span.attributes[maskCandidate]);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
applyMask(data) {
|
|
204
|
+
if (!this.mask)
|
|
205
|
+
return data;
|
|
206
|
+
try {
|
|
207
|
+
return this.mask({ data });
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
this.logger.warn(`Applying mask function failed due to error, fully masking property. Error: ${err}`);
|
|
211
|
+
return "<fully masked due to failed mask function>";
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async processMediaItem({ media, traceId, observationId, field, }) {
|
|
215
|
+
try {
|
|
216
|
+
if (!media.contentLength ||
|
|
217
|
+
!media._contentType ||
|
|
218
|
+
!media.contentSha256Hash ||
|
|
219
|
+
!media._contentBytes) {
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const { uploadUrl, mediaId } = await this.apiClient.media.getUploadUrl({
|
|
223
|
+
contentLength: media.contentLength,
|
|
224
|
+
traceId,
|
|
225
|
+
observationId,
|
|
226
|
+
field,
|
|
227
|
+
contentType: media._contentType,
|
|
228
|
+
sha256Hash: media.contentSha256Hash,
|
|
229
|
+
});
|
|
230
|
+
if (!uploadUrl) {
|
|
231
|
+
this.logger.debug(`Media status: Media with ID ${media.id} already uploaded. Skipping duplicate upload.`);
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
if (media.id !== mediaId) {
|
|
235
|
+
this.logger.error(`Media integrity error: Media ID mismatch between SDK (${media.id}) and Server (${mediaId}). Upload cancelled. Please check media ID generation logic.`);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
this.logger.debug(`Uploading media ${mediaId}...`);
|
|
239
|
+
const startTime = Date.now();
|
|
240
|
+
const uploadResponse = await this.uploadMediaWithBackoff({
|
|
241
|
+
uploadUrl,
|
|
242
|
+
contentBytes: media._contentBytes,
|
|
243
|
+
contentType: media._contentType,
|
|
244
|
+
contentSha256Hash: media.contentSha256Hash,
|
|
245
|
+
maxRetries: 3,
|
|
246
|
+
baseDelay: 1000,
|
|
247
|
+
});
|
|
248
|
+
if (!uploadResponse) {
|
|
249
|
+
throw Error("Media upload process failed");
|
|
250
|
+
}
|
|
251
|
+
await this.apiClient.media.patch(mediaId, {
|
|
252
|
+
uploadedAt: new Date().toISOString(),
|
|
253
|
+
uploadHttpStatus: uploadResponse.status,
|
|
254
|
+
uploadHttpError: await uploadResponse.text(),
|
|
255
|
+
uploadTimeMs: Date.now() - startTime,
|
|
256
|
+
});
|
|
257
|
+
this.logger.debug(`Media upload status reported for ${mediaId}`);
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
this.logger.error(`Error processing media item: ${err}`);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
async uploadMediaWithBackoff(params) {
|
|
264
|
+
const { uploadUrl, contentType, contentSha256Hash, contentBytes, maxRetries, baseDelay, } = params;
|
|
265
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
266
|
+
try {
|
|
267
|
+
const uploadResponse = await fetch(uploadUrl, {
|
|
268
|
+
method: "PUT",
|
|
269
|
+
body: contentBytes,
|
|
270
|
+
headers: {
|
|
271
|
+
"Content-Type": contentType,
|
|
272
|
+
"x-amz-checksum-sha256": contentSha256Hash,
|
|
273
|
+
"x-ms-blob-type": "BlockBlob",
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
if (attempt < maxRetries &&
|
|
277
|
+
uploadResponse.status !== 200 &&
|
|
278
|
+
uploadResponse.status !== 201) {
|
|
279
|
+
throw new Error(`Upload failed with status ${uploadResponse.status}`);
|
|
280
|
+
}
|
|
281
|
+
return uploadResponse;
|
|
282
|
+
}
|
|
283
|
+
catch (e) {
|
|
284
|
+
if (attempt === maxRetries) {
|
|
285
|
+
throw e;
|
|
286
|
+
}
|
|
287
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
288
|
+
const jitter = Math.random() * 1000;
|
|
289
|
+
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@langfuse/otel",
|
|
3
|
+
"version": "4.0.0-alpha.0",
|
|
4
|
+
"author": "Langfuse",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=20"
|
|
8
|
+
},
|
|
9
|
+
"description": "Langfuse OpenTelemetry export helpers",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"sideEffects": false,
|
|
12
|
+
"main": "./dist/index.cjs",
|
|
13
|
+
"module": "./dist/index.mjs",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.mjs",
|
|
19
|
+
"require": "./dist/index.cjs"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist"
|
|
24
|
+
],
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@langfuse/core": "^4.0.0-alpha.0"
|
|
27
|
+
},
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@opentelemetry/api": "^1.9.0",
|
|
30
|
+
"@opentelemetry/core": "^2.0.1",
|
|
31
|
+
"@opentelemetry/exporter-trace-otlp-http": "^0.202.0",
|
|
32
|
+
"@opentelemetry/sdk-trace-base": "^2.0.1"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsup",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest",
|
|
38
|
+
"format": "prettier --write \"src/**/*.ts\"",
|
|
39
|
+
"format:check": "prettier --check \"src/**/*.ts\"",
|
|
40
|
+
"clean": "rm -rf dist"
|
|
41
|
+
}
|
|
42
|
+
}
|