weflayr 0.1.2 → 0.2.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/.env.example +3 -0
- package/README.md +62 -117
- package/package.json +6 -45
- package/src/index.js +29 -0
- package/src/instrument.js +281 -0
- package/src/openai.js +0 -116
- package/src/weflayr.js +0 -229
package/.env.example
ADDED
package/README.md
CHANGED
|
@@ -1,147 +1,92 @@
|
|
|
1
|
-
# Weflayr
|
|
1
|
+
# Weflayr Node.js SDK
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Observability for Node.js - instrument any client or method with one line.
|
|
4
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
|
|
5
|
+
## Install
|
|
15
6
|
|
|
16
7
|
```bash
|
|
17
8
|
npm install weflayr
|
|
18
9
|
```
|
|
19
10
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
---
|
|
23
|
-
|
|
24
|
-
## Implementation steps
|
|
25
|
-
|
|
26
|
-
### 1. Get your credentials
|
|
27
|
-
|
|
28
|
-
Sign in at [weflayr.com](https://weflayr.com), create a **Flayr**, and copy your `client_id` and `client_secret`.
|
|
29
|
-
|
|
30
|
-
### 2. Set environment variables
|
|
11
|
+
## Environment variables
|
|
31
12
|
|
|
32
13
|
```bash
|
|
33
14
|
WEFLAYR_INTAKE_URL=https://api.weflayr.com
|
|
34
|
-
WEFLAYR_CLIENT_ID
|
|
35
|
-
WEFLAYR_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());
|
|
15
|
+
WEFLAYR_CLIENT_ID=your-client-id-uuid
|
|
16
|
+
WEFLAYR_CLIENT_SECRET=your-client-secret
|
|
59
17
|
```
|
|
60
18
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
No other changes needed.
|
|
19
|
+
## Usage
|
|
64
20
|
|
|
65
21
|
```js
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
|
|
22
|
+
const { weflayr_setup, weflayr_instrument } = require('weflayr');
|
|
23
|
+
|
|
24
|
+
weflayr_setup({
|
|
25
|
+
event_mode: 'default', // 'default' | 'light' (light skips before events)
|
|
26
|
+
content_policy: 'ignore', // 'ignore' removes ignore_fields | 'allow' keeps only allow_fields
|
|
27
|
+
ignore_fields: ['messages[].content', 'choices[].message.content'],
|
|
28
|
+
allow_fields: [],
|
|
29
|
+
methods: [
|
|
30
|
+
{ call: 'chat.completions.create' }
|
|
31
|
+
],
|
|
32
|
+
}, { enabled: true });
|
|
33
|
+
|
|
34
|
+
const OpenAI = require('openai');
|
|
35
|
+
const client = weflayr_instrument(new OpenAI({ apiKey: process.env.OPENAI_API_KEY }));
|
|
36
|
+
|
|
37
|
+
// Tags are stripped from args before the API call and attached to the event
|
|
38
|
+
const response = await client.chat.completions.create({
|
|
39
|
+
model: 'gpt-4o-mini',
|
|
40
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
41
|
+
__weflayr_tags: { feature: 'chat', customer_id: '42' },
|
|
69
42
|
});
|
|
70
|
-
|
|
71
|
-
console.log(response.choices[0].message.content);
|
|
72
43
|
```
|
|
73
44
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
Pass a `tags` object in any call params to attach metadata — useful for slicing analytics by feature, user, or environment.
|
|
45
|
+
## Instrumenting a plain function
|
|
77
46
|
|
|
78
47
|
```js
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
48
|
+
async function fetchModels() {
|
|
49
|
+
const res = await fetch('https://api.example.com/models');
|
|
50
|
+
return res.json();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
weflayr_setup({
|
|
54
|
+
event_mode: 'default',
|
|
55
|
+
content_policy: 'ignore',
|
|
56
|
+
ignore_fields: ['data'],
|
|
57
|
+
methods: [
|
|
58
|
+
{
|
|
59
|
+
call: 'fetchModels',
|
|
60
|
+
middleware: (_args, response) => ({ count: response?.data?.length ?? 0 }),
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
}, { enabled: true });
|
|
64
|
+
|
|
65
|
+
const instrumented = weflayr_instrument(fetchModels);
|
|
66
|
+
const models = await instrumented({ __weflayr_tags: { app: 'my-app' } });
|
|
84
67
|
```
|
|
85
68
|
|
|
86
|
-
|
|
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
|
-
---
|
|
69
|
+
## Settings reference
|
|
107
70
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
Each call emits up to two events to your intake API:
|
|
111
|
-
|
|
112
|
-
| Event | When | Key fields |
|
|
71
|
+
| Field | Type | Description |
|
|
113
72
|
|---|---|---|
|
|
114
|
-
|
|
|
115
|
-
|
|
|
116
|
-
|
|
|
117
|
-
|
|
118
|
-
|
|
73
|
+
| `event_mode` | `'default'` \| `'light'` | `light` skips before events |
|
|
74
|
+
| `content_policy` | `'ignore'` \| `'allow'` | Which filtering mode to apply |
|
|
75
|
+
| `ignore_fields` | `string[]` | Fields removed from payloads (used when `content_policy: 'ignore'`) |
|
|
76
|
+
| `allow_fields` | `string[]` | Fields kept in payloads; everything else removed (used when `content_policy: 'allow'`) |
|
|
77
|
+
| `methods` | `{ call, middleware? }[]` | Whitelisted method paths to instrument |
|
|
119
78
|
|
|
120
|
-
|
|
79
|
+
Field paths support dot notation (`foo.bar`), and array traversal (`messages[].content`).
|
|
121
80
|
|
|
122
|
-
|
|
81
|
+
## Middleware
|
|
123
82
|
|
|
124
|
-
|
|
83
|
+
Middleware runs on both **before** and **after** events. `response` is `null` for before events.
|
|
125
84
|
|
|
126
85
|
```js
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const openai = instrument(new OpenAI());
|
|
133
|
-
|
|
134
|
-
const response = await openai.chat.completions.create({
|
|
135
|
-
model: "gpt-4o-mini",
|
|
136
|
-
messages: [{ role: "user", content: "What is 2 + 2?" }],
|
|
137
|
-
tags: { feature: "math", env: "production" },
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
console.log(response.choices[0].message.content);
|
|
86
|
+
middleware: (args, response) => ({
|
|
87
|
+
char_count: args?.input?.length ?? 0,
|
|
88
|
+
result_count: response?.data?.length ?? 0,
|
|
89
|
+
})
|
|
141
90
|
```
|
|
142
91
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
## License
|
|
146
|
-
|
|
147
|
-
[Elastic License 2.0](LICENSE) — free to use, modifications and redistribution not permitted.
|
|
92
|
+
The returned object is merged into the event payload.
|
package/package.json
CHANGED
|
@@ -1,57 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "weflayr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
5
|
-
"
|
|
6
|
-
"exports": {
|
|
7
|
-
".": "./src/weflayr.js",
|
|
8
|
-
"./openai": "./src/openai.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"src/"
|
|
12
|
-
],
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Weflayr Node.js SDK — instrument any LLM client via JS Proxy",
|
|
5
|
+
"main": "src/index.js",
|
|
13
6
|
"scripts": {
|
|
14
|
-
"test": "node --test tests/
|
|
15
|
-
"prepublishOnly": "node --input-type=module --eval \"import './src/weflayr.js'\" 2>/dev/null; echo 'pre-publish check passed'"
|
|
7
|
+
"test": "node --test tests/"
|
|
16
8
|
},
|
|
17
|
-
"keywords": [
|
|
18
|
-
"llm",
|
|
19
|
-
"telemetry",
|
|
20
|
-
"observability",
|
|
21
|
-
"openai",
|
|
22
|
-
"instrumentation",
|
|
23
|
-
"tracing",
|
|
24
|
-
"ai",
|
|
25
|
-
"otel",
|
|
26
|
-
"opentelemetry"
|
|
27
|
-
],
|
|
9
|
+
"keywords": ["llm", "observability", "weflayr"],
|
|
28
10
|
"author": "Weflayr <contact@weflayr.com>",
|
|
29
11
|
"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.git"
|
|
34
|
-
},
|
|
35
|
-
"bugs": {
|
|
36
|
-
"url": "https://github.com/WeFlayr/public-mirror-js-sdk/issues"
|
|
37
|
-
},
|
|
38
12
|
"engines": {
|
|
39
13
|
"node": ">=18.0.0"
|
|
40
14
|
},
|
|
41
15
|
"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
|
-
}
|
|
16
|
+
"dotenv": "^16.0.0"
|
|
56
17
|
}
|
|
57
18
|
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
require('dotenv').config();
|
|
4
|
+
|
|
5
|
+
const { configure, weflayr_instrument } = require('./instrument');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
*
|
|
9
|
+
* @param {*} settings: settings for weflayr
|
|
10
|
+
* @param {*} on/off: easily enable or disable weflayr
|
|
11
|
+
* @returns
|
|
12
|
+
*/
|
|
13
|
+
function weflayr_setup(settings, { enabled = true } = {}) {
|
|
14
|
+
if (!enabled) return;
|
|
15
|
+
|
|
16
|
+
const intakeUrl = process.env.WEFLAYR_INTAKE_URL;
|
|
17
|
+
const clientId = process.env.WEFLAYR_CLIENT_ID;
|
|
18
|
+
const clientSecret = process.env.WEFLAYR_CLIENT_SECRET;
|
|
19
|
+
|
|
20
|
+
if (!intakeUrl || !clientId || !clientSecret) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'Weflayr: WEFLAYR_INTAKE_URL, WEFLAYR_CLIENT_ID and WEFLAYR_CLIENT_SECRET must be set'
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
configure(intakeUrl, clientId, clientSecret, settings);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { weflayr_setup, weflayr_instrument };
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { randomUUID } = require('crypto');
|
|
6
|
+
|
|
7
|
+
let _config = null;
|
|
8
|
+
|
|
9
|
+
function configure(intakeUrl, clientId, clientSecret, settings) {
|
|
10
|
+
_config = { intakeUrl, clientId, clientSecret, settings };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function weflayr_instrument(target) {
|
|
14
|
+
if (!_config) return target;
|
|
15
|
+
|
|
16
|
+
if (typeof target === 'function') {
|
|
17
|
+
const methodConfig = _config.settings.methods.find(m => m.call === target.name);
|
|
18
|
+
if (!methodConfig) return target;
|
|
19
|
+
return _wrapFn(target, target.name, methodConfig);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (target && typeof target === 'object') {
|
|
23
|
+
return _makeProxy(target, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return target;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function _makeProxy(target, pathPrefix) {
|
|
30
|
+
return new Proxy(target, {
|
|
31
|
+
get(obj, prop) {
|
|
32
|
+
if (typeof prop === 'symbol') return Reflect.get(obj, prop);
|
|
33
|
+
|
|
34
|
+
const value = Reflect.get(obj, prop);
|
|
35
|
+
const fullPath = pathPrefix ? `${pathPrefix}.${prop}` : prop;
|
|
36
|
+
|
|
37
|
+
if (typeof value === 'function') {
|
|
38
|
+
const methodConfig = _config.settings.methods.find(m => m.call === fullPath);
|
|
39
|
+
if (methodConfig) {
|
|
40
|
+
return _wrapFn(value.bind(obj), fullPath, methodConfig);
|
|
41
|
+
}
|
|
42
|
+
return value.bind(obj);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (value && typeof value === 'object') {
|
|
46
|
+
return _makeProxy(value, fullPath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return value;
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _wrapFn(fn, methodName, methodConfig) {
|
|
55
|
+
return async function (...args) {
|
|
56
|
+
const { tags, cleanArgs } = _extractTags(args);
|
|
57
|
+
const startTime = Date.now();
|
|
58
|
+
const { settings } = _config;
|
|
59
|
+
const eventId = randomUUID();
|
|
60
|
+
|
|
61
|
+
if (settings.event_mode !== 'light') {
|
|
62
|
+
const beforeMiddlewareData = methodConfig.middleware
|
|
63
|
+
? methodConfig.middleware(cleanArgs[0], null) || {}
|
|
64
|
+
: {};
|
|
65
|
+
_sendEvent('before', {
|
|
66
|
+
event_id: eventId,
|
|
67
|
+
method: methodName,
|
|
68
|
+
tags,
|
|
69
|
+
args: _applyContentPolicy(cleanArgs[0], settings),
|
|
70
|
+
...beforeMiddlewareData,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const result = await fn(...cleanArgs);
|
|
76
|
+
|
|
77
|
+
if (_isAsyncIterable(result)) {
|
|
78
|
+
_sendEvent('stream_start', { event_id: eventId, method: methodName, tags });
|
|
79
|
+
return _wrapStream(result, methodName, tags, cleanArgs[0], settings, startTime, methodConfig, eventId);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const middlewareData = methodConfig.middleware
|
|
83
|
+
? methodConfig.middleware(cleanArgs[0], result) || {}
|
|
84
|
+
: {};
|
|
85
|
+
|
|
86
|
+
_sendEvent('after', {
|
|
87
|
+
event_id: eventId,
|
|
88
|
+
method: methodName,
|
|
89
|
+
tags,
|
|
90
|
+
args: _applyContentPolicy(cleanArgs[0], settings),
|
|
91
|
+
response: _applyContentPolicy(_toPlain(result), settings),
|
|
92
|
+
elapsed_ms: Date.now() - startTime,
|
|
93
|
+
...middlewareData,
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return result;
|
|
97
|
+
} catch (err) {
|
|
98
|
+
_sendEvent('after', {
|
|
99
|
+
event_id: eventId,
|
|
100
|
+
method: methodName,
|
|
101
|
+
tags,
|
|
102
|
+
args: _applyContentPolicy(cleanArgs[0], settings),
|
|
103
|
+
error: err.message,
|
|
104
|
+
status_code: err.status,
|
|
105
|
+
elapsed_ms: Date.now() - startTime,
|
|
106
|
+
});
|
|
107
|
+
throw err;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function* _wrapStream(stream, methodName, tags, requestArgs, settings, startTime, methodConfig, eventId) {
|
|
113
|
+
let lastChunk = null;
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
for await (const chunk of stream) {
|
|
117
|
+
lastChunk = chunk;
|
|
118
|
+
yield chunk;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const middlewareData = methodConfig.middleware && lastChunk
|
|
122
|
+
? methodConfig.middleware(requestArgs, lastChunk) || {}
|
|
123
|
+
: {};
|
|
124
|
+
|
|
125
|
+
_sendEvent('after', {
|
|
126
|
+
event_id: eventId,
|
|
127
|
+
method: methodName,
|
|
128
|
+
tags,
|
|
129
|
+
args: _applyContentPolicy(requestArgs, settings),
|
|
130
|
+
response: _applyContentPolicy(_toPlain(lastChunk), settings),
|
|
131
|
+
elapsed_ms: Date.now() - startTime,
|
|
132
|
+
...middlewareData,
|
|
133
|
+
});
|
|
134
|
+
} catch (err) {
|
|
135
|
+
_sendEvent('after', {
|
|
136
|
+
event_id: eventId,
|
|
137
|
+
method: methodName,
|
|
138
|
+
tags,
|
|
139
|
+
args: _applyContentPolicy(requestArgs, settings),
|
|
140
|
+
error: err.message,
|
|
141
|
+
elapsed_ms: Date.now() - startTime,
|
|
142
|
+
});
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _extractTags(args) {
|
|
148
|
+
let tags = {};
|
|
149
|
+
const cleanArgs = args.map(arg => {
|
|
150
|
+
if (arg && typeof arg === 'object' && !Array.isArray(arg) && '__weflayr_tags' in arg) {
|
|
151
|
+
const { __weflayr_tags, ...rest } = arg;
|
|
152
|
+
tags = __weflayr_tags || {};
|
|
153
|
+
return rest;
|
|
154
|
+
}
|
|
155
|
+
return arg;
|
|
156
|
+
});
|
|
157
|
+
return { tags, cleanArgs };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _isAsyncIterable(value) {
|
|
161
|
+
return value != null && typeof value[Symbol.asyncIterator] === 'function';
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _toPlain(value) {
|
|
165
|
+
if (!value) return value;
|
|
166
|
+
if (typeof value.toJSON === 'function') return value.toJSON();
|
|
167
|
+
return value;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _applyContentPolicy(data, settings) {
|
|
171
|
+
if (!data) return data;
|
|
172
|
+
|
|
173
|
+
let clone;
|
|
174
|
+
try {
|
|
175
|
+
clone = JSON.parse(JSON.stringify(data));
|
|
176
|
+
} catch {
|
|
177
|
+
return data;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (settings.content_policy === 'allow') {
|
|
181
|
+
return _pickFields(clone, settings.allow_fields || []);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
for (const field of (settings.ignore_fields || [])) {
|
|
185
|
+
_deleteField(clone, field);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return clone;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _pickFields(obj, allowFields) {
|
|
192
|
+
if (!obj || typeof obj !== 'object' || allowFields.length === 0) return obj;
|
|
193
|
+
|
|
194
|
+
const result = {};
|
|
195
|
+
for (const field of allowFields) {
|
|
196
|
+
const arrayMatch = field.match(/^(\w+)\[\]\.(.+)$/);
|
|
197
|
+
if (arrayMatch) {
|
|
198
|
+
const [, arrayKey, rest] = arrayMatch;
|
|
199
|
+
if (Array.isArray(obj[arrayKey])) {
|
|
200
|
+
result[arrayKey] = obj[arrayKey].map(item => _pickFields(item, [rest]));
|
|
201
|
+
}
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const dotIdx = field.indexOf('.');
|
|
206
|
+
if (dotIdx === -1) {
|
|
207
|
+
if (field in obj) result[field] = obj[field];
|
|
208
|
+
} else {
|
|
209
|
+
const key = field.slice(0, dotIdx);
|
|
210
|
+
const rest = field.slice(dotIdx + 1);
|
|
211
|
+
if (key in obj) result[key] = _pickFields(obj[key], [rest]);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _deleteField(obj, path) {
|
|
218
|
+
if (!obj || typeof obj !== 'object') return;
|
|
219
|
+
|
|
220
|
+
const arrayMatch = path.match(/^(\w+)\[\]\.(.+)$/);
|
|
221
|
+
if (arrayMatch) {
|
|
222
|
+
const [, arrayKey, rest] = arrayMatch;
|
|
223
|
+
if (Array.isArray(obj[arrayKey])) {
|
|
224
|
+
obj[arrayKey].forEach(item => _deleteField(item, rest));
|
|
225
|
+
}
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const dotIdx = path.indexOf('.');
|
|
230
|
+
if (dotIdx === -1) {
|
|
231
|
+
delete obj[path];
|
|
232
|
+
} else {
|
|
233
|
+
_deleteField(obj[path.slice(0, dotIdx)], path.slice(dotIdx + 1));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function _sendEvent(eventType, data) {
|
|
238
|
+
const { intakeUrl, clientId, clientSecret } = _config;
|
|
239
|
+
const endpoint = `${intakeUrl.replace(/\/$/, '')}/${clientId}/`;
|
|
240
|
+
|
|
241
|
+
const body = JSON.stringify({
|
|
242
|
+
event_type: eventType,
|
|
243
|
+
timestamp: new Date().toISOString(),
|
|
244
|
+
...data,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
let parsed;
|
|
248
|
+
try {
|
|
249
|
+
parsed = new URL(endpoint);
|
|
250
|
+
} catch {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const lib = parsed.protocol === 'https:' ? https : http;
|
|
255
|
+
const req = lib.request({
|
|
256
|
+
hostname: parsed.hostname,
|
|
257
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
258
|
+
path: parsed.pathname,
|
|
259
|
+
method: 'POST',
|
|
260
|
+
headers: {
|
|
261
|
+
'Content-Type': 'application/json',
|
|
262
|
+
'Content-Length': Buffer.byteLength(body),
|
|
263
|
+
'Authorization': `Bearer ${clientSecret}`,
|
|
264
|
+
},
|
|
265
|
+
}, res => {
|
|
266
|
+
res.resume();
|
|
267
|
+
if (res.statusCode >= 400) {
|
|
268
|
+
console.warn(`[weflayr] intake returned ${res.statusCode} for event "${eventType}"`);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
req.on('error', (err) => {
|
|
273
|
+
console.warn(`[weflayr] send error for event "${eventType}":`, err.message);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
req.write(body);
|
|
277
|
+
|
|
278
|
+
req.end();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
module.exports = { configure, weflayr_instrument };
|
package/src/openai.js
DELETED
|
@@ -1,116 +0,0 @@
|
|
|
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
|
-
const providerOpts = { provider: "openai" };
|
|
108
|
-
|
|
109
|
-
// 1. Patch all explicitly defined routes with their precise extractors.
|
|
110
|
-
for (const route of ROUTES) {
|
|
111
|
-
route.set(client, makeWrapper(route.get(client), route, tracer, providerOpts));
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// 2. Wrap the whole client in a Proxy that handles anything not in ROUTES.
|
|
115
|
-
return deepFallbackProxy(client, tracer, PATCHED_PATHS, "", providerOpts);
|
|
116
|
-
}
|
package/src/weflayr.js
DELETED
|
@@ -1,229 +0,0 @@
|
|
|
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, { provider } = {}) {
|
|
121
|
-
return async function ({ tags: callTags, ...params }) {
|
|
122
|
-
const tags = { ...GLOBAL_TAGS, ...callTags };
|
|
123
|
-
const before = { ...(provider ? { provider } : {}), ...route.before(params) };
|
|
124
|
-
const span = tracer.startSpan(route.name, {
|
|
125
|
-
attributes: {
|
|
126
|
-
"weflayr.event_id": randomUUID(),
|
|
127
|
-
"weflayr.before": JSON.stringify(before),
|
|
128
|
-
"weflayr.tags": JSON.stringify(tags),
|
|
129
|
-
},
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
try {
|
|
133
|
-
const response = await original(params);
|
|
134
|
-
span.setAttribute("weflayr.after", JSON.stringify(route.after(response)));
|
|
135
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
136
|
-
return response;
|
|
137
|
-
} catch (err) {
|
|
138
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
139
|
-
span.setAttribute("error.type", err.constructor.name);
|
|
140
|
-
span.setAttribute("error.message", err.message);
|
|
141
|
-
throw err;
|
|
142
|
-
} finally {
|
|
143
|
-
span.end();
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export function makeFallbackWrapper(fn, target, name, tracer, { provider } = {}) {
|
|
149
|
-
return function (...args) {
|
|
150
|
-
let callArgs = args;
|
|
151
|
-
let tags = GLOBAL_TAGS;
|
|
152
|
-
|
|
153
|
-
// If the first argument is a plain params object, extract `tags` from it.
|
|
154
|
-
if (args[0] !== null && typeof args[0] === "object" && !Array.isArray(args[0])) {
|
|
155
|
-
const { tags: callTags, ...rest } = args[0];
|
|
156
|
-
callArgs = [rest, ...args.slice(1)];
|
|
157
|
-
tags = { ...GLOBAL_TAGS, ...callTags };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const before = { ...(provider ? { provider } : {}), model: callArgs[0]?.model };
|
|
161
|
-
const span = tracer.startSpan(name, {
|
|
162
|
-
attributes: {
|
|
163
|
-
"weflayr.event_id": randomUUID(),
|
|
164
|
-
"weflayr.before": JSON.stringify(before),
|
|
165
|
-
"weflayr.tags": JSON.stringify(tags),
|
|
166
|
-
},
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
let result;
|
|
170
|
-
try {
|
|
171
|
-
result = fn.apply(target, callArgs);
|
|
172
|
-
} catch (err) {
|
|
173
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
174
|
-
span.setAttribute("error.type", err.constructor.name);
|
|
175
|
-
span.setAttribute("error.message", err.message);
|
|
176
|
-
span.end();
|
|
177
|
-
throw err;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Handle both sync and async return values.
|
|
181
|
-
if (result && typeof result.then === "function") {
|
|
182
|
-
return result.then(
|
|
183
|
-
(response) => {
|
|
184
|
-
span.setAttribute("weflayr.after", "{}");
|
|
185
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
186
|
-
span.end();
|
|
187
|
-
return response;
|
|
188
|
-
},
|
|
189
|
-
(err) => {
|
|
190
|
-
span.setStatus({ code: SpanStatusCode.ERROR, message: err.message });
|
|
191
|
-
span.setAttribute("error.type", err.constructor.name);
|
|
192
|
-
span.setAttribute("error.message", err.message);
|
|
193
|
-
span.end();
|
|
194
|
-
throw err;
|
|
195
|
-
}
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
span.setAttribute("weflayr.after", "{}");
|
|
200
|
-
span.setStatus({ code: SpanStatusCode.OK });
|
|
201
|
-
span.end();
|
|
202
|
-
return result;
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// Recursively wraps every function on `obj` that is not already covered by patchedPaths.
|
|
207
|
-
// `path` tracks the dotted property path (e.g. "images.generate").
|
|
208
|
-
export function deepFallbackProxy(obj, tracer, patchedPaths, path = "", opts = {}) {
|
|
209
|
-
return new Proxy(obj, {
|
|
210
|
-
get(target, prop) {
|
|
211
|
-
if (typeof prop !== "string") return Reflect.get(target, prop);
|
|
212
|
-
|
|
213
|
-
const val = Reflect.get(target, prop);
|
|
214
|
-
const fullPath = path ? `${path}.${prop}` : prop;
|
|
215
|
-
|
|
216
|
-
if (typeof val === "function") {
|
|
217
|
-
// Already patched by explicit routes — return the existing wrapper as-is.
|
|
218
|
-
if (patchedPaths.has(fullPath)) return val;
|
|
219
|
-
return makeFallbackWrapper(val, target, fullPath, tracer, opts);
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
if (val !== null && typeof val === "object") {
|
|
223
|
-
return deepFallbackProxy(val, tracer, patchedPaths, fullPath, opts);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
return val;
|
|
227
|
-
},
|
|
228
|
-
});
|
|
229
|
-
}
|