weflayr 0.1.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/README.md ADDED
@@ -0,0 +1,157 @@
1
+ # Weflayr JS SDK
2
+
3
+ Drop-in instrumented wrapper for the OpenAI Node.js SDK — add telemetry to your LLM calls in two lines.
4
+
5
+ ```
6
+ your code → instrument(openai) → OpenAI SDK → OpenAI API
7
+
8
+ Weflayr Intake API
9
+ (before · after · error events, fire-and-forget)
10
+ ```
11
+
12
+ ---
13
+
14
+ ## Installation
15
+
16
+ ```bash
17
+ npm install weflayr
18
+ ```
19
+
20
+ Requires Node.js 18+ and `openai>=4.0.0`.
21
+
22
+ ---
23
+
24
+ ## Implementation steps
25
+
26
+ ### 1. Get your credentials
27
+
28
+ Sign in at [weflayr.com](https://weflayr.com), create a **Flare**, and copy your `client_id` and `client_secret`.
29
+
30
+ ### 2. Set environment variables
31
+
32
+ ```bash
33
+ WEFLAYR_INTAKE_URL=https://api.weflayr.com
34
+ WEFLAYR_CLIENT_ID=<your-client-id>
35
+ WEFLAYR_CLIENT_SECRET=<your-client-secret>
36
+ ```
37
+
38
+ Or add them to a `.env` file — the SDK uses `dotenv` automatically.
39
+
40
+ ### 3. Initialize Weflayr at startup
41
+
42
+ Call `setupWeflayr()` once, before any LLM calls. This registers the OpenTelemetry trace provider that powers telemetry.
43
+
44
+ ```js
45
+ import { setupWeflayr } from "weflayr/openai";
46
+
47
+ await setupWeflayr();
48
+ ```
49
+
50
+ ### 4. Instrument your OpenAI client
51
+
52
+ Wrap your existing client with `instrument()`. The returned client is a drop-in replacement.
53
+
54
+ ```js
55
+ import OpenAI from "openai";
56
+ import { instrument } from "weflayr/openai";
57
+
58
+ const openai = instrument(new OpenAI());
59
+ ```
60
+
61
+ ### 5. Use it normally
62
+
63
+ No other changes needed.
64
+
65
+ ```js
66
+ const response = await openai.chat.completions.create({
67
+ model: "gpt-4o-mini",
68
+ messages: [{ role: "user", content: "Hello!" }],
69
+ });
70
+
71
+ console.log(response.choices[0].message.content);
72
+ ```
73
+
74
+ ### 6. (Optional) Add tags
75
+
76
+ Pass a `tags` object in any call params to attach metadata — useful for slicing analytics by feature, user, or environment.
77
+
78
+ ```js
79
+ const response = await openai.chat.completions.create({
80
+ model: "gpt-4o-mini",
81
+ messages: [{ role: "user", content: "Summarize this." }],
82
+ tags: { feature: "summarization", userId: "u_123", env: "production" },
83
+ });
84
+ ```
85
+
86
+ Tags are stripped before the request reaches OpenAI — they never affect the API call.
87
+
88
+ ---
89
+
90
+ ## Covered endpoints
91
+
92
+ All token-consuming OpenAI endpoints are instrumented with precise extractors. Any endpoint not in the list below is wrapped automatically by a fallback proxy.
93
+
94
+ | Endpoint | Fields tracked |
95
+ |---|---|
96
+ | `chat.completions.create` | `model`, `message_count`, `prompt_tokens`, `completion_tokens` |
97
+ | `completions.create` (legacy) | `model`, `prompt_length`, `prompt_tokens`, `completion_tokens` |
98
+ | `embeddings.create` | `model`, `input_count`, `prompt_tokens`, `total_tokens` |
99
+ | `responses.create` | `model`, `input_count`, `input_tokens`, `output_tokens`, `cached_tokens` |
100
+ | `audio.speech.create` (TTS) | `model`, `voice`, `char_count` |
101
+ | `audio.transcriptions.create` (STT) | `model`, `language`, token/duration usage |
102
+ | `audio.translations.create` | `model`, token/duration usage |
103
+
104
+ Any other method on the client is wrapped by a fallback proxy and tracked under its dotted path (e.g. `images.generate`).
105
+
106
+ ---
107
+
108
+ ## Telemetry events
109
+
110
+ Each call emits up to two events to your intake API:
111
+
112
+ | Event | When | Key fields |
113
+ |---|---|---|
114
+ | `<endpoint>.before` | Before the call | `model`, call-specific params, `tags` |
115
+ | `<endpoint>.after` | On success | all `.before` fields + `elapsed_ms` + token usage |
116
+ | `<endpoint>.error` | On failure | all `.before` fields + `elapsed_ms` + `error_type`, `error_message` |
117
+
118
+ Events are sent **fire-and-forget** — they never block your code or throw.
119
+
120
+ By default, message content and prompt text are stripped before sending. Fields omitted: `messages`, `prompt`, `response_content`.
121
+
122
+ ---
123
+
124
+ ## Optional: forward traces to your own collector
125
+
126
+ Set `WEFLAYR_COLLECTOR_ENDPOINT` to also send OTLP traces to a collector of your choice (e.g. Jaeger, Grafana Tempo):
127
+
128
+ ```bash
129
+ WEFLAYR_COLLECTOR_ENDPOINT=http://localhost:4318/v1/traces
130
+ ```
131
+
132
+ ---
133
+
134
+ ## Full example
135
+
136
+ ```js
137
+ import OpenAI from "openai";
138
+ import { setupWeflayr, instrument } from "weflayr/openai";
139
+
140
+ await setupWeflayr();
141
+
142
+ const openai = instrument(new OpenAI());
143
+
144
+ const response = await openai.chat.completions.create({
145
+ model: "gpt-4o-mini",
146
+ messages: [{ role: "user", content: "What is 2 + 2?" }],
147
+ tags: { feature: "math", env: "production" },
148
+ });
149
+
150
+ console.log(response.choices[0].message.content);
151
+ ```
152
+
153
+ ---
154
+
155
+ ## License
156
+
157
+ [Elastic License 2.0](LICENSE) — free to use, modifications and redistribution not permitted.
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "weflayr",
3
+ "version": "0.1.0",
4
+ "description": "Drop-in instrumented wrappers for AI clients with zero-overhead telemetry",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/weflayr.js",
8
+ "./openai": "./src/openai.js"
9
+ },
10
+ "files": [
11
+ "src/"
12
+ ],
13
+ "scripts": {
14
+ "test": "node --test tests/weflayr.test.js tests/openai.test.js",
15
+ "prepublishOnly": "node --input-type=module --eval \"import './src/weflayr.js'\" 2>/dev/null; echo 'pre-publish check passed'"
16
+ },
17
+ "keywords": [
18
+ "llm",
19
+ "telemetry",
20
+ "observability",
21
+ "openai",
22
+ "instrumentation",
23
+ "tracing",
24
+ "ai",
25
+ "otel",
26
+ "opentelemetry"
27
+ ],
28
+ "author": "Weflayr <contact@weflayr.com>",
29
+ "license": "Elastic-2.0",
30
+ "homepage": "https://weflayr.com",
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/WeFlayr/public-mirror-js-sdk"
34
+ },
35
+ "bugs": {
36
+ "url": "https://github.com/WeFlayr/public-mirror-js-sdk/issues"
37
+ },
38
+ "engines": {
39
+ "node": ">=18.0.0"
40
+ },
41
+ "dependencies": {
42
+ "dotenv": "^16.0.0",
43
+ "@opentelemetry/api": "^1.9.0",
44
+ "@opentelemetry/sdk-trace-base": "^1.30.0"
45
+ },
46
+ "optionalDependencies": {
47
+ "@opentelemetry/exporter-trace-otlp-http": "^0.57.0"
48
+ },
49
+ "peerDependencies": {
50
+ "openai": ">=4.0.0"
51
+ },
52
+ "peerDependenciesMeta": {
53
+ "openai": {
54
+ "optional": true
55
+ }
56
+ }
57
+ }
package/src/openai.js ADDED
@@ -0,0 +1,114 @@
1
+ import { trace } from "@opentelemetry/api";
2
+ import { setupWeflayr, makeWrapper, deepFallbackProxy } from "./weflayr.js";
3
+
4
+ export { setupWeflayr };
5
+
6
+ // ── STT usage helper ──────────────────────────────────────────────────────────
7
+ function sttUsage(r) {
8
+ const usage = r?.usage;
9
+ if (!usage) return {};
10
+ if (usage.type === "tokens") {
11
+ return {
12
+ usage_type: "tokens",
13
+ input_tokens: usage.input_tokens,
14
+ audio_tokens: usage.input_token_details?.audio_tokens,
15
+ };
16
+ }
17
+ if (usage.type === "duration") {
18
+ return { usage_type: "duration", duration_seconds: usage.seconds };
19
+ }
20
+ return {};
21
+ }
22
+
23
+ // ── Route map ─────────────────────────────────────────────────────────────────
24
+ // Each entry defines one instrumented OpenAI method:
25
+ // name — event_type prefix sent to Weflayr
26
+ // get — retrieves the original bound method from the client
27
+ // set — patches the method back on the client
28
+ // before — extracts fields from call params for the .before event
29
+ // after — extracts fields from the response for the .after event
30
+
31
+ const ROUTES = [
32
+ // client.chat.completions.create
33
+ {
34
+ name: "chat.completions.create",
35
+ get: (c) => c.chat.completions.create.bind(c.chat.completions),
36
+ set: (c, fn) => { c.chat.completions.create = fn; },
37
+ before: (p) => ({ model: p.model, message_count: p.messages?.length ?? 0 }),
38
+ after: (r) => ({ prompt_tokens: r.usage?.prompt_tokens, completion_tokens: r.usage?.completion_tokens }),
39
+ },
40
+
41
+ // client.completions.create (legacy text completions)
42
+ {
43
+ name: "completions.create",
44
+ get: (c) => c.completions.create.bind(c.completions),
45
+ set: (c, fn) => { c.completions.create = fn; },
46
+ before: (p) => ({ model: p.model, prompt_length: typeof p.prompt === "string" ? p.prompt.length : (p.prompt?.length ?? 0) }),
47
+ after: (r) => ({ prompt_tokens: r.usage?.prompt_tokens, completion_tokens: r.usage?.completion_tokens }),
48
+ },
49
+
50
+ // client.embeddings.create
51
+ {
52
+ name: "embeddings.create",
53
+ get: (c) => c.embeddings.create.bind(c.embeddings),
54
+ set: (c, fn) => { c.embeddings.create = fn; },
55
+ before: (p) => ({ model: p.model, input_count: Array.isArray(p.input) ? p.input.length : 1 }),
56
+ after: (r) => ({ prompt_tokens: r.usage?.prompt_tokens, total_tokens: r.usage?.total_tokens }),
57
+ },
58
+
59
+ // client.responses.create (Responses API)
60
+ {
61
+ name: "responses.create",
62
+ get: (c) => c.responses.create.bind(c.responses),
63
+ set: (c, fn) => { c.responses.create = fn; },
64
+ before: (p) => ({ model: p.model, input_count: Array.isArray(p.input) ? p.input.length : 1 }),
65
+ after: (r) => ({
66
+ input_tokens: r.usage?.input_tokens,
67
+ output_tokens: r.usage?.output_tokens,
68
+ cached_tokens: r.usage?.input_tokens_details?.cached_tokens,
69
+ }),
70
+ },
71
+
72
+ // client.audio.speech.create (TTS — billed by char count)
73
+ {
74
+ name: "audio.speech.create",
75
+ get: (c) => c.audio.speech.create.bind(c.audio.speech),
76
+ set: (c, fn) => { c.audio.speech.create = fn; },
77
+ before: (p) => ({ model: p.model, voice: p.voice, char_count: p.input?.length ?? 0 }),
78
+ after: () => ({}),
79
+ },
80
+
81
+ // client.audio.transcriptions.create (STT)
82
+ {
83
+ name: "audio.transcriptions.create",
84
+ get: (c) => c.audio.transcriptions.create.bind(c.audio.transcriptions),
85
+ set: (c, fn) => { c.audio.transcriptions.create = fn; },
86
+ before: (p) => ({ model: p.model, language: p.language }),
87
+ after: (r) => sttUsage(r),
88
+ },
89
+
90
+ // client.audio.translations.create (whisper-1 only — billed by duration)
91
+ {
92
+ name: "audio.translations.create",
93
+ get: (c) => c.audio.translations.create.bind(c.audio.translations),
94
+ set: (c, fn) => { c.audio.translations.create = fn; },
95
+ before: (p) => ({ model: p.model }),
96
+ after: (r) => sttUsage(r),
97
+ },
98
+ ];
99
+ // ─────────────────────────────────────────────────────────────────────────────
100
+
101
+ // Paths already covered by ROUTES — the fallback Proxy skips these.
102
+ const PATCHED_PATHS = new Set(ROUTES.map((r) => r.name));
103
+
104
+ export function instrument(client) {
105
+ const tracer = trace.getTracer("weflayr-openai");
106
+
107
+ // 1. Patch all explicitly defined routes with their precise extractors.
108
+ for (const route of ROUTES) {
109
+ route.set(client, makeWrapper(route.get(client), route, tracer));
110
+ }
111
+
112
+ // 2. Wrap the whole client in a Proxy that handles anything not in ROUTES.
113
+ return deepFallbackProxy(client, tracer, PATCHED_PATHS);
114
+ }
package/src/weflayr.js ADDED
@@ -0,0 +1,228 @@
1
+ import "dotenv/config";
2
+ import { SpanStatusCode } from "@opentelemetry/api";
3
+ import {
4
+ BasicTracerProvider,
5
+ SimpleSpanProcessor,
6
+ } from "@opentelemetry/sdk-trace-base";
7
+ import { randomUUID } from "node:crypto";
8
+
9
+ // ── Weflayr Intake API ────────────────────────────────────────────────────────
10
+ export const INTAKE_URL = (
11
+ process.env.WEFLAYR_INTAKE_URL ?? "https://api.weflayr.com"
12
+ ).replace(/\/$/, "");
13
+ export const CLIENT_ID = process.env.WEFLAYR_CLIENT_ID ?? "";
14
+ export const CLIENT_SECRET = process.env.WEFLAYR_CLIENT_SECRET ?? "";
15
+
16
+ // ── INFO TO SEND ──────────────────────────────────────────────────────────────
17
+ // Global tags attached to every event. Populate via env vars or hardcode values.
18
+ // Per-call tags can also be passed directly in the create() params (see main.js).
19
+ export const GLOBAL_TAGS = Object.fromEntries(
20
+ Object.entries({
21
+ env: process.env.WEFLAYR_TAG_ENV,
22
+ feature: process.env.WEFLAYR_TAG_FEATURE,
23
+ version: process.env.WEFLAYR_TAG_VERSION,
24
+ }).filter(([, v]) => v != null)
25
+ );
26
+
27
+ // ── INFO TO HIDE ──────────────────────────────────────────────────────────────
28
+ // Keys stripped from every Weflayr event before it is sent.
29
+ // Prevents PII or sensitive content from leaving the process.
30
+ export const HIDDEN_FIELDS = new Set([
31
+ "messages", // prompt message content (chat completions)
32
+ "prompt", // prompt text (legacy completions)
33
+ "response_content", // completion response text
34
+ ]);
35
+ // ─────────────────────────────────────────────────────────────────────────────
36
+
37
+ export async function _post(payload) {
38
+ if (!CLIENT_ID || !CLIENT_SECRET) return;
39
+
40
+ for (const key of HIDDEN_FIELDS) delete payload[key];
41
+ for (const [k, v] of Object.entries(payload)) {
42
+ if (v == null) delete payload[k];
43
+ }
44
+
45
+ try {
46
+ await fetch(`${INTAKE_URL}/${CLIENT_ID}/`, {
47
+ method: "POST",
48
+ headers: {
49
+ "Content-Type": "application/json",
50
+ Authorization: `Bearer ${CLIENT_SECRET}`,
51
+ },
52
+ body: JSON.stringify(payload),
53
+ });
54
+ } catch {
55
+ // fire-and-forget — never throws
56
+ }
57
+ }
58
+
59
+ export class WeflayrSpanProcessor {
60
+ onStart(span) {
61
+ const a = span.attributes;
62
+ const before = JSON.parse(a["weflayr.before"] ?? "{}");
63
+ const tags = JSON.parse(a["weflayr.tags"] ?? "{}");
64
+ _post({
65
+ event_id: a["weflayr.event_id"],
66
+ event_type: `${span.name}.before`,
67
+ ...before,
68
+ tags: Object.keys(tags).length ? tags : undefined,
69
+ });
70
+ }
71
+
72
+ onEnd(span) {
73
+ const [ss, sns] = span.startTime;
74
+ const [es, ens] = span.endTime;
75
+ const elapsed_ms = Math.round((es - ss) * 1000 + (ens - sns) / 1_000_000);
76
+
77
+ const isError = span.status.code === SpanStatusCode.ERROR;
78
+ const a = span.attributes;
79
+ const before = JSON.parse(a["weflayr.before"] ?? "{}");
80
+ const after = JSON.parse(a["weflayr.after"] ?? "{}");
81
+ const tags = JSON.parse(a["weflayr.tags"] ?? "{}");
82
+
83
+ _post({
84
+ event_id: a["weflayr.event_id"],
85
+ event_type: `${span.name}.${isError ? "error" : "after"}`,
86
+ ...before,
87
+ ...after,
88
+ elapsed_ms,
89
+ tags: Object.keys(tags).length ? tags : undefined,
90
+ ...(isError
91
+ ? { error_type: a["error.type"], error_message: a["error.message"] }
92
+ : {}),
93
+ });
94
+ }
95
+
96
+ forceFlush() { return Promise.resolve(); }
97
+ shutdown() { return Promise.resolve(); }
98
+ }
99
+
100
+ export async function setupWeflayr() {
101
+ // Direct path: always send to the Weflayr Intake API
102
+ const spanProcessors = [new WeflayrSpanProcessor()];
103
+
104
+ // Optional collector path: also forward OTLP traces to your own collector.
105
+ // Set WEFLAYR_COLLECTOR_ENDPOINT in .env to enable (e.g. http://localhost:4318/v1/traces).
106
+ if (process.env.WEFLAYR_COLLECTOR_ENDPOINT) {
107
+ const { OTLPTraceExporter } = await import(
108
+ "@opentelemetry/exporter-trace-otlp-http"
109
+ );
110
+ spanProcessors.push(
111
+ new SimpleSpanProcessor(
112
+ new OTLPTraceExporter({ url: process.env.WEFLAYR_COLLECTOR_ENDPOINT })
113
+ )
114
+ );
115
+ }
116
+
117
+ new BasicTracerProvider({ spanProcessors }).register();
118
+ }
119
+
120
+ export function makeWrapper(original, route, tracer) {
121
+ return async function ({ tags: callTags, ...params }) {
122
+ const tags = { ...GLOBAL_TAGS, ...callTags };
123
+ const span = tracer.startSpan(route.name, {
124
+ attributes: {
125
+ "weflayr.event_id": randomUUID(),
126
+ "weflayr.before": JSON.stringify(route.before(params)),
127
+ "weflayr.tags": JSON.stringify(tags),
128
+ },
129
+ });
130
+
131
+ try {
132
+ const response = await original(params);
133
+ span.setAttribute("weflayr.after", JSON.stringify(route.after(response)));
134
+ span.setStatus({ code: SpanStatusCode.OK });
135
+ return response;
136
+ } catch (err) {
137
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
138
+ span.setAttribute("error.type", err.constructor.name);
139
+ span.setAttribute("error.message", err.message);
140
+ throw err;
141
+ } finally {
142
+ span.end();
143
+ }
144
+ };
145
+ }
146
+
147
+ export function makeFallbackWrapper(fn, target, name, tracer) {
148
+ return function (...args) {
149
+ let callArgs = args;
150
+ let tags = GLOBAL_TAGS;
151
+
152
+ // If the first argument is a plain params object, extract `tags` from it.
153
+ if (args[0] !== null && typeof args[0] === "object" && !Array.isArray(args[0])) {
154
+ const { tags: callTags, ...rest } = args[0];
155
+ callArgs = [rest, ...args.slice(1)];
156
+ tags = { ...GLOBAL_TAGS, ...callTags };
157
+ }
158
+
159
+ const before = { model: callArgs[0]?.model };
160
+ const span = tracer.startSpan(name, {
161
+ attributes: {
162
+ "weflayr.event_id": randomUUID(),
163
+ "weflayr.before": JSON.stringify(before),
164
+ "weflayr.tags": JSON.stringify(tags),
165
+ },
166
+ });
167
+
168
+ let result;
169
+ try {
170
+ result = fn.apply(target, callArgs);
171
+ } catch (err) {
172
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
173
+ span.setAttribute("error.type", err.constructor.name);
174
+ span.setAttribute("error.message", err.message);
175
+ span.end();
176
+ throw err;
177
+ }
178
+
179
+ // Handle both sync and async return values.
180
+ if (result && typeof result.then === "function") {
181
+ return result.then(
182
+ (response) => {
183
+ span.setAttribute("weflayr.after", "{}");
184
+ span.setStatus({ code: SpanStatusCode.OK });
185
+ span.end();
186
+ return response;
187
+ },
188
+ (err) => {
189
+ span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
190
+ span.setAttribute("error.type", err.constructor.name);
191
+ span.setAttribute("error.message", err.message);
192
+ span.end();
193
+ throw err;
194
+ }
195
+ );
196
+ }
197
+
198
+ span.setAttribute("weflayr.after", "{}");
199
+ span.setStatus({ code: SpanStatusCode.OK });
200
+ span.end();
201
+ return result;
202
+ };
203
+ }
204
+
205
+ // Recursively wraps every function on `obj` that is not already covered by patchedPaths.
206
+ // `path` tracks the dotted property path (e.g. "images.generate").
207
+ export function deepFallbackProxy(obj, tracer, patchedPaths, path = "") {
208
+ return new Proxy(obj, {
209
+ get(target, prop) {
210
+ if (typeof prop !== "string") return Reflect.get(target, prop);
211
+
212
+ const val = Reflect.get(target, prop);
213
+ const fullPath = path ? `${path}.${prop}` : prop;
214
+
215
+ if (typeof val === "function") {
216
+ // Already patched by explicit routes — return the existing wrapper as-is.
217
+ if (patchedPaths.has(fullPath)) return val;
218
+ return makeFallbackWrapper(val, target, fullPath, tracer);
219
+ }
220
+
221
+ if (val !== null && typeof val === "object") {
222
+ return deepFallbackProxy(val, tracer, patchedPaths, fullPath);
223
+ }
224
+
225
+ return val;
226
+ },
227
+ });
228
+ }