@genn-inc/cluebase-frontend-sdk 0.0.1 → 0.0.2
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 +11 -9
- package/dist/_generated/correlation-attribute-keys.js +4 -0
- package/dist/_generated/otel-span-conversion.d.ts +0 -34
- package/dist/_generated/otel-span-conversion.js +0 -32
- package/dist/context/action-context.d.ts +39 -0
- package/dist/context/action-context.js +298 -0
- package/dist/context/browser-storage.d.ts +0 -12
- package/dist/context/browser-storage.js +0 -13
- package/dist/context/identity.js +0 -4
- package/dist/context/organization.d.ts +0 -4
- package/dist/context/organization.js +0 -7
- package/dist/context/session.js +0 -3
- package/dist/context/tab.js +0 -5
- package/dist/context/trace.d.ts +13 -1
- package/dist/context/trace.js +119 -21
- package/dist/exporter/batch.d.ts +0 -12
- package/dist/exporter/batch.js +0 -27
- package/dist/exporter/browser-outbox.js +0 -17
- package/dist/exporter/client.d.ts +0 -15
- package/dist/exporter/client.js +0 -43
- package/dist/exporter/delivery-response-contract.js +0 -6
- package/dist/exporter/delivery-response.d.ts +0 -16
- package/dist/exporter/delivery-response.js +0 -19
- package/dist/exporter/frontend-resource.d.ts +0 -9
- package/dist/exporter/frontend-resource.js +0 -9
- package/dist/exporter/observation-source-event-exporter.d.ts +0 -28
- package/dist/exporter/observation-source-event-exporter.js +0 -18
- package/dist/exporter/parameter-snapshot.js +1 -24
- package/dist/exporter/raw-event-to-source-signal.js +18 -11
- package/dist/exporter/sanitize.d.ts +1 -12
- package/dist/exporter/sanitize.js +6 -39
- package/dist/exporter/source-event.d.ts +2 -0
- package/dist/exporter/span-to-source-signal.js +7 -3
- package/dist/exporter/text-fingerprint.d.ts +0 -8
- package/dist/exporter/text-fingerprint.js +0 -8
- package/dist/index.d.ts +0 -4
- package/dist/index.js +9 -39
- package/dist/instrumentation/action.js +3 -2
- package/dist/instrumentation/cluebase-authored-attributes.d.ts +0 -2
- package/dist/instrumentation/cluebase-authored-attributes.js +0 -23
- package/dist/instrumentation/common.d.ts +0 -63
- package/dist/instrumentation/common.js +0 -48
- package/dist/instrumentation/dom.d.ts +0 -25
- package/dist/instrumentation/dom.js +5 -41
- package/dist/instrumentation/drag.js +1 -2
- package/dist/instrumentation/form.js +1 -2
- package/dist/instrumentation/input.js +10 -8
- package/dist/instrumentation/naming-evidence.d.ts +0 -12
- package/dist/instrumentation/navigation.js +0 -2
- package/dist/instrumentation/network-request.d.ts +2 -2
- package/dist/instrumentation/network-request.js +1 -2
- package/dist/instrumentation/network-span-attributes.d.ts +0 -7
- package/dist/instrumentation/network-span-attributes.js +1 -3
- package/dist/instrumentation/network.js +6 -1
- package/dist/instrumentation/page.js +0 -2
- package/dist/instrumentation/performance.js +0 -7
- package/dist/integrations/react-router.js +0 -3
- package/dist/internal/internal-log.d.ts +0 -7
- package/dist/internal/internal-log.js +0 -11
- package/dist/provider/_version.d.ts +1 -1
- package/dist/provider/_version.js +1 -1
- package/dist/provider/config.d.ts +0 -7
- package/dist/provider/config.js +3 -16
- package/dist/provider/constants.d.ts +1 -17
- package/dist/provider/constants.js +1 -31
- package/dist/provider/privacy-policy-store.d.ts +0 -12
- package/dist/provider/privacy-policy-store.js +0 -12
- package/dist/provider/sdk-event-pipeline.d.ts +1 -1
- package/dist/provider/sdk-event-pipeline.js +41 -54
- package/dist/provider/sdk-otel-pipeline.js +50 -4
- package/dist/provider/sdk-runtime-surfaces.js +3 -1
- package/dist/provider/sdk.js +3 -23
- package/dist/provider/types.d.ts +3 -39
- package/dist/provider/usage-governor.js +0 -2
- package/package.json +3 -6
|
@@ -92,32 +92,13 @@ const invalidResponse = () => ({
|
|
|
92
92
|
acceptedPartial: false,
|
|
93
93
|
reason: "invalid_response",
|
|
94
94
|
});
|
|
95
|
-
/**
|
|
96
|
-
* Decide from the HTTP status alone whether a failed exchange with Cluebase is
|
|
97
|
-
* worth replaying.
|
|
98
|
-
*
|
|
99
|
-
* This is the single owner of that judgement. Batch delivery consults it when
|
|
100
|
-
* the body carries no usable verdict, and browser-token acquisition consults it
|
|
101
|
-
* because that endpoint carries no verdict at all. Without one owner the same
|
|
102
|
-
* status is permanent on one path and transient on the other, and a request
|
|
103
|
-
* that can never succeed is replayed until the customer's events expire.
|
|
104
|
-
*
|
|
105
|
-
* A status is only replayed when it says "the same request may succeed later".
|
|
106
|
-
* That is the rule the SDK's own core already applies — OpenTelemetry's OTLP
|
|
107
|
-
* exporter retries 429/502/503/504 and nothing else (`isExportRetryable`) —
|
|
108
|
-
* widened here to the statuses this ingest contract additionally defines.
|
|
109
|
-
*/
|
|
110
95
|
export const classifyIngestHttpStatus = (status) => {
|
|
111
|
-
// A 2xx that got this far means the body did not match the contract. That can
|
|
112
|
-
// be a transient rewrite in transit, so it stays replayable.
|
|
113
96
|
if (status >= 200 && status < 300)
|
|
114
97
|
return "retry_same_batch";
|
|
115
98
|
if (status === 409)
|
|
116
99
|
return "mark_failed";
|
|
117
100
|
if (status === 413)
|
|
118
101
|
return "split_batch";
|
|
119
|
-
// Deterministic refusals: the request is wrong, unauthorized, or aimed at
|
|
120
|
-
// something that is not there. Replaying it changes nothing.
|
|
121
102
|
if ([400, 401, 403, 404, 405, 406, 410, 415].includes(status)) {
|
|
122
103
|
return "stop_and_report";
|
|
123
104
|
}
|
|
@@ -6,15 +6,6 @@ export type FrontendResourceInput = FrontendResourceAttributes | {
|
|
|
6
6
|
type ResourceRecord = {
|
|
7
7
|
[key: string]: string;
|
|
8
8
|
};
|
|
9
|
-
/**
|
|
10
|
-
* Build the only Resource attributes emitted by the browser SDK's own OTel
|
|
11
|
-
* provider. Values are intentionally strings so the same snapshot can be
|
|
12
|
-
* used to construct an OTel Resource and to enrich raw source signals.
|
|
13
|
-
*/
|
|
14
9
|
export declare const createFrontendResourceAttributes: (resolved: ResolvedCluebaseConfig) => FrontendResourceAttributes;
|
|
15
|
-
/**
|
|
16
|
-
* Copy only approved frontend Resource attributes into the serialized source
|
|
17
|
-
* signal. The returned object is a detached, bounded JSON-safe snapshot.
|
|
18
|
-
*/
|
|
19
10
|
export declare const toFrontendSourceResource: (resource: FrontendResourceInput | undefined, deniedKeys?: readonly string[]) => ResourceRecord;
|
|
20
11
|
export {};
|
|
@@ -4,11 +4,6 @@ import { CLUEBASE_OTEL_SDK_SOURCE_REQUIRED_RESOURCE_KEYS, CLUEBASE_OTEL_SDK_SOUR
|
|
|
4
4
|
const FRONTEND_RESOURCE_KEYS = new Set(CLUEBASE_OTEL_SDK_SOURCE_RESOURCE_KEYS);
|
|
5
5
|
const FRONTEND_REQUIRED_RESOURCE_KEYS = new Set(CLUEBASE_OTEL_SDK_SOURCE_REQUIRED_RESOURCE_KEYS);
|
|
6
6
|
const MAX_RESOURCE_VALUE_BYTES = 512;
|
|
7
|
-
/**
|
|
8
|
-
* Build the only Resource attributes emitted by the browser SDK's own OTel
|
|
9
|
-
* provider. Values are intentionally strings so the same snapshot can be
|
|
10
|
-
* used to construct an OTel Resource and to enrich raw source signals.
|
|
11
|
-
*/
|
|
12
7
|
export const createFrontendResourceAttributes = (resolved) => ({
|
|
13
8
|
"service.name": resolved.producerId,
|
|
14
9
|
"service.version": resolved.release.frontendRelease ?? "unknown",
|
|
@@ -20,10 +15,6 @@ export const createFrontendResourceAttributes = (resolved) => ({
|
|
|
20
15
|
"cluebase.sdk.collection_mode": resolved.collection?.mode ?? "standard",
|
|
21
16
|
"cluebase.runtime_language": "browser",
|
|
22
17
|
});
|
|
23
|
-
/**
|
|
24
|
-
* Copy only approved frontend Resource attributes into the serialized source
|
|
25
|
-
* signal. The returned object is a detached, bounded JSON-safe snapshot.
|
|
26
|
-
*/
|
|
27
18
|
export const toFrontendSourceResource = (resource, deniedKeys = []) => {
|
|
28
19
|
const input = resource && "attributes" in resource ? resource.attributes : resource;
|
|
29
20
|
const output = {};
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser-SDK observation source event exporter.
|
|
3
|
-
*
|
|
4
|
-
* Production OTel {@link SpanExporter} for the Frontend SDK. Forwards each
|
|
5
|
-
* {@link ReadableSpan} as a source-signal envelope and routes it onto the
|
|
6
|
-
* SDK's existing transport buffer. Final classification and canonical shaping
|
|
7
|
-
* happen in the Cluebase backend ingestion core.
|
|
8
|
-
*
|
|
9
|
-
* The exporter is registered onto the global `WebTracerProvider` in
|
|
10
|
-
* `provider/sdk.ts:cluebase.init`. `enqueue(event)` accepts source-signal
|
|
11
|
-
* envelopes from capture modules.
|
|
12
|
-
*/
|
|
13
1
|
import { type ExportResult, ExportResultCode } from "@opentelemetry/core";
|
|
14
2
|
import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base";
|
|
15
3
|
import type { CluebaseContext } from "../provider/types.js";
|
|
@@ -22,20 +10,8 @@ export interface ObservationSourceEventExporterOptions {
|
|
|
22
10
|
getCollectionPermit?: (span: ReadableSpan | null) => CollectionPermit | undefined;
|
|
23
11
|
flushImpl: () => Promise<void>;
|
|
24
12
|
shutdownImpl?: () => Promise<void>;
|
|
25
|
-
/**
|
|
26
|
-
* Returns the active {@link CluebaseContext} for the current SDK instance.
|
|
27
|
-
* Called once per `export()` invocation so browser-environment fields
|
|
28
|
-
* (device, browser, locale, etc.) reflect the SDK state at export time.
|
|
29
|
-
*/
|
|
30
13
|
getContext: () => CluebaseContext;
|
|
31
14
|
deniedKeys?: readonly string[];
|
|
32
|
-
/**
|
|
33
|
-
* URL prefixes that should NOT produce source-signal events.
|
|
34
|
-
* Used to skip spans that the Cluebase ingest BatchTransport itself
|
|
35
|
-
* generates when the host installs ``@opentelemetry/instrumentation-fetch``
|
|
36
|
-
* (otherwise Cluebase's own POSTs would loop back through the exporter as
|
|
37
|
-
* fetch spans, generating duplicate ingest events ad-infinitum).
|
|
38
|
-
*/
|
|
39
15
|
ignoreUrlPrefixes?: readonly string[];
|
|
40
16
|
}
|
|
41
17
|
export declare class ObservationSourceEventExporter implements SpanExporter {
|
|
@@ -52,10 +28,6 @@ export declare class ObservationSourceEventExporter implements SpanExporter {
|
|
|
52
28
|
private skippedLoopSpanCount;
|
|
53
29
|
constructor(options: ObservationSourceEventExporterOptions);
|
|
54
30
|
private ignoreSpanReason;
|
|
55
|
-
/**
|
|
56
|
-
* Enqueue a source-signal event emitted by capture modules through
|
|
57
|
-
* `context.onEvent`.
|
|
58
|
-
*/
|
|
59
31
|
enqueue(event: BrowserSourceSignalEvent): void;
|
|
60
32
|
export(spans: ReadableSpan[], resultCallback: (result: ExportResult) => void): void;
|
|
61
33
|
forceFlush(): Promise<void>;
|
|
@@ -1,15 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Browser-SDK observation source event exporter.
|
|
3
|
-
*
|
|
4
|
-
* Production OTel {@link SpanExporter} for the Frontend SDK. Forwards each
|
|
5
|
-
* {@link ReadableSpan} as a source-signal envelope and routes it onto the
|
|
6
|
-
* SDK's existing transport buffer. Final classification and canonical shaping
|
|
7
|
-
* happen in the Cluebase backend ingestion core.
|
|
8
|
-
*
|
|
9
|
-
* The exporter is registered onto the global `WebTracerProvider` in
|
|
10
|
-
* `provider/sdk.ts:cluebase.init`. `enqueue(event)` accepts source-signal
|
|
11
|
-
* envelopes from capture modules.
|
|
12
|
-
*/
|
|
13
1
|
import { ExportResultCode } from "@opentelemetry/core";
|
|
14
2
|
import { logSwallowed } from "../internal/internal-log.js";
|
|
15
3
|
import { spanToSourceSignalEvent } from "./span-to-source-signal.js";
|
|
@@ -50,10 +38,6 @@ export class ObservationSourceEventExporter {
|
|
|
50
38
|
}
|
|
51
39
|
return null;
|
|
52
40
|
}
|
|
53
|
-
/**
|
|
54
|
-
* Enqueue a source-signal event emitted by capture modules through
|
|
55
|
-
* `context.onEvent`.
|
|
56
|
-
*/
|
|
57
41
|
enqueue(event) {
|
|
58
42
|
if (this.closed)
|
|
59
43
|
return;
|
|
@@ -122,7 +106,6 @@ export class ObservationSourceEventExporter {
|
|
|
122
106
|
await this.flushImpl();
|
|
123
107
|
}
|
|
124
108
|
catch (cause) {
|
|
125
|
-
// forceFlush is best-effort; swallow to keep the OTel contract.
|
|
126
109
|
logSwallowed("forceFlush", cause);
|
|
127
110
|
}
|
|
128
111
|
}
|
|
@@ -139,7 +122,6 @@ export class ObservationSourceEventExporter {
|
|
|
139
122
|
}
|
|
140
123
|
}
|
|
141
124
|
catch (cause) {
|
|
142
|
-
// shutdown is non-raising per the OTel contract.
|
|
143
125
|
logSwallowed("shutdown", cause);
|
|
144
126
|
}
|
|
145
127
|
}
|
|
@@ -36,21 +36,13 @@ const measureSerializedBytes = (value) => {
|
|
|
36
36
|
return undefined;
|
|
37
37
|
}
|
|
38
38
|
};
|
|
39
|
-
/**
|
|
40
|
-
* 値をそのまま載せてよいのは、**顧客が画面で名指しした項目だけ**。
|
|
41
|
-
*
|
|
42
|
-
* 載せるのは文字・数・真偽に限ります。入れ子は葉に来ないので、それ以外の形が
|
|
43
|
-
* ここへ来たら伏せた側へ倒します。
|
|
44
|
-
*/
|
|
45
39
|
const releasedLeafValue = (value) => value === null ||
|
|
46
40
|
typeof value === "string" ||
|
|
47
41
|
typeof value === "number" ||
|
|
48
42
|
typeof value === "boolean"
|
|
49
43
|
? value
|
|
50
44
|
: MASKED_VALUE;
|
|
51
|
-
const buildLeaf = (value, captureMode,
|
|
52
|
-
// 顧客がこの項目を名指ししたか。**既定は false = 伏せる。**
|
|
53
|
-
released = false) => {
|
|
45
|
+
const buildLeaf = (value, captureMode, released = false) => {
|
|
54
46
|
const leafType = inferLeafType(value);
|
|
55
47
|
const originalSizeBytes = captureMode === PARAMETER_CAPTURE_MODE.forbidden
|
|
56
48
|
? undefined
|
|
@@ -71,20 +63,6 @@ released = false) => {
|
|
|
71
63
|
export const buildUnavailableParameterSnapshot = () => ({
|
|
72
64
|
[SNAPSHOT_UNAVAILABLE_KEY]: buildLeaf("snapshot_unavailable", PARAMETER_CAPTURE_MODE.maskedFingerprint),
|
|
73
65
|
});
|
|
74
|
-
/**
|
|
75
|
-
* その節は葉か、降りる枝か。
|
|
76
|
-
*
|
|
77
|
-
* **見分ける手順をここに書きません。** ここには鍵の有無だけを見る版が書かれており、
|
|
78
|
-
* 受付側は型まで見ていました。`{ type: 123, capture_mode: null, value: 1 }` が
|
|
79
|
-
* **送る側にとって葉、受け取る側にとって枝**という状態で、受け取った側は中へ降りて
|
|
80
|
-
* `type` / `value` / `capture_mode` を項目名として扱っていました。降りる側の 1 つは
|
|
81
|
-
* 伏せる判定です。
|
|
82
|
-
*
|
|
83
|
-
* 手順は正本(`packages/cluebase-schema/.../observation-classification.schema.ts`)から
|
|
84
|
-
* 生成され、受付側も同じものを受け取ります。共有の部品は顧客の手元で解決できない
|
|
85
|
-
* ので取り込めず、**生成して同梱する**のがこの repo の作法です
|
|
86
|
-
* (`_generated/ingest-privacy-tables.ts` と同じ)。
|
|
87
|
-
*/
|
|
88
66
|
const isParameterSnapshotLeaf = (value) => hasParameterSnapshotLeafShape(value);
|
|
89
67
|
export const truncateParameterSnapshotValue = (value) => {
|
|
90
68
|
if (Array.isArray(value)) {
|
|
@@ -197,7 +175,6 @@ export const buildParameterSnapshotBundle = (value, options) => {
|
|
|
197
175
|
: null;
|
|
198
176
|
const captureMode = PARAMETER_CAPTURE_MODE.maskedFingerprint;
|
|
199
177
|
return {
|
|
200
|
-
// 名指しされた項目だけ値を持つ。名指ししていない項目は伏せたまま。
|
|
201
178
|
snapshot: buildLeaf(current, captureMode, matchedAllowedPath !== null),
|
|
202
179
|
allowedValueKeys: matchedAllowedPath ? [matchedAllowedPath] : [],
|
|
203
180
|
};
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// 相関のために残す属性名は、正本から生成した部品を読みます。ここには手で書かれて
|
|
2
|
-
// おり、もう 1 か所の写しと 15 語食い違い、`cluebase.session_id` `mcp.session.id` が
|
|
3
|
-
// 欠けていて**落ちていました**(`session` を含むため落とす側に当たる)。
|
|
4
1
|
import { CLUEBASE_CORRELATION_ATTRIBUTE_KEYS } from "../_generated/correlation-attribute-keys.js";
|
|
5
2
|
import { cluebaseAuthoredPropertyAttribute } from "../instrumentation/cluebase-authored-attributes.js";
|
|
6
3
|
import { EVENT_SOURCE, EVENT_VERSION, SOURCE_SCHEMA_VERSION, } from "../provider/constants.js";
|
|
@@ -11,7 +8,7 @@ import { SDK_SOURCE_SIGNAL_EVENT_NAME, SDK_SOURCE_SIGNAL_EVENT_TYPE, } from "./s
|
|
|
11
8
|
export const RAW_FRONTEND_SOURCE_SIGNAL_KIND = "frontend_raw_event";
|
|
12
9
|
const PUBLIC_CUSTOM_EVENT_NAME_PATTERN = /^[A-Za-z0-9_.:-]{1,128}$/;
|
|
13
10
|
const isDynamicPayloadObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
14
|
-
function toJsonValue(value, deniedKeys = [], onMaskingError) {
|
|
11
|
+
function toJsonValue(value, deniedKeys = [], onMaskingError, preserveExplicitProperties = false) {
|
|
15
12
|
if (value === null)
|
|
16
13
|
return null;
|
|
17
14
|
if (typeof value === "string" ||
|
|
@@ -20,7 +17,7 @@ function toJsonValue(value, deniedKeys = [], onMaskingError) {
|
|
|
20
17
|
return typeof value === "number" && !Number.isFinite(value) ? null : value;
|
|
21
18
|
}
|
|
22
19
|
if (Array.isArray(value)) {
|
|
23
|
-
return value.map((entry) => toJsonValue(entry, deniedKeys, onMaskingError));
|
|
20
|
+
return value.map((entry) => toJsonValue(entry, deniedKeys, onMaskingError, preserveExplicitProperties));
|
|
24
21
|
}
|
|
25
22
|
if (isDynamicPayloadObject(value)) {
|
|
26
23
|
const record = {};
|
|
@@ -33,11 +30,12 @@ function toJsonValue(value, deniedKeys = [], onMaskingError) {
|
|
|
33
30
|
return record;
|
|
34
31
|
}
|
|
35
32
|
for (const [key, entry] of entries) {
|
|
36
|
-
if (!
|
|
33
|
+
if (!preserveExplicitProperties &&
|
|
34
|
+
!CLUEBASE_CORRELATION_ATTRIBUTE_KEYS.has(key) &&
|
|
37
35
|
isDeniedKey(key, deniedKeys)) {
|
|
38
36
|
continue;
|
|
39
37
|
}
|
|
40
|
-
record[key] = toJsonValue(entry, deniedKeys, onMaskingError);
|
|
38
|
+
record[key] = toJsonValue(entry, deniedKeys, onMaskingError, preserveExplicitProperties);
|
|
41
39
|
}
|
|
42
40
|
return record;
|
|
43
41
|
}
|
|
@@ -45,7 +43,7 @@ function toJsonValue(value, deniedKeys = [], onMaskingError) {
|
|
|
45
43
|
return value.toString();
|
|
46
44
|
return null;
|
|
47
45
|
}
|
|
48
|
-
function toJsonRecord(value, deniedKeys = [], onMaskingError) {
|
|
46
|
+
function toJsonRecord(value, deniedKeys = [], onMaskingError, preserveExplicitProperties = false) {
|
|
49
47
|
const record = {};
|
|
50
48
|
let entries;
|
|
51
49
|
try {
|
|
@@ -60,7 +58,10 @@ function toJsonRecord(value, deniedKeys = [], onMaskingError) {
|
|
|
60
58
|
isDeniedKey(key, deniedKeys)) {
|
|
61
59
|
continue;
|
|
62
60
|
}
|
|
63
|
-
record[key] =
|
|
61
|
+
record[key] =
|
|
62
|
+
preserveExplicitProperties && key === "properties"
|
|
63
|
+
? toJsonValue(entry, deniedKeys, onMaskingError, true)
|
|
64
|
+
: toJsonValue(entry, deniedKeys, onMaskingError);
|
|
64
65
|
}
|
|
65
66
|
return record;
|
|
66
67
|
}
|
|
@@ -111,6 +112,7 @@ function contextRecord(context, overrides = {}) {
|
|
|
111
112
|
trace_id: overrides.trace_id ?? context.trace_id,
|
|
112
113
|
request_id: overrides.request_id ?? context.request_id,
|
|
113
114
|
request_span_id: overrides.request_span_id ?? context.request_span_id,
|
|
115
|
+
action_id: overrides.action_id ?? context.action_id,
|
|
114
116
|
interaction_id: overrides.interaction_id ?? context.interaction_id,
|
|
115
117
|
correlation_id: context.correlation_id,
|
|
116
118
|
sdk_collection_mode: context.sdk_collection_mode,
|
|
@@ -146,7 +148,8 @@ export function rawEventToSourceSignalEvent(raw, options) {
|
|
|
146
148
|
const eventId = generateEventId();
|
|
147
149
|
const occurredAtMs = raw.occurredAtMs ?? Date.now();
|
|
148
150
|
const occurredAt = new Date(occurredAtMs).toISOString();
|
|
149
|
-
const
|
|
151
|
+
const customEventOrigin = raw.type === "custom_emitted" ? trustedCustomOrigin(raw.payload) : null;
|
|
152
|
+
const payload = toJsonRecord(raw.payload, options.deniedKeys, options.onMaskingError, customEventOrigin === "explicit_track");
|
|
150
153
|
dropNonFiniteMetrics(payload);
|
|
151
154
|
const requestId = stringPayload(raw.payload, "requestId") ??
|
|
152
155
|
context.request_id ??
|
|
@@ -154,6 +157,7 @@ export function rawEventToSourceSignalEvent(raw, options) {
|
|
|
154
157
|
const requestSpanId = stringPayload(raw.payload, "requestSpanId") ??
|
|
155
158
|
context.request_span_id ??
|
|
156
159
|
generateRequestSpanId();
|
|
160
|
+
const actionId = stringPayload(raw.payload, "actionId") ?? context.action_id ?? null;
|
|
157
161
|
const traceId = stringPayload(raw.payload, "traceId") ??
|
|
158
162
|
context.trace_id ??
|
|
159
163
|
generateTraceId();
|
|
@@ -169,7 +173,6 @@ export function rawEventToSourceSignalEvent(raw, options) {
|
|
|
169
173
|
"cluebase.raw_event.payload": payload,
|
|
170
174
|
"cluebase.event_source": EVENT_SOURCE,
|
|
171
175
|
};
|
|
172
|
-
const customEventOrigin = raw.type === "custom_emitted" ? trustedCustomOrigin(raw.payload) : null;
|
|
173
176
|
const customEventName = raw.type === "custom_emitted" && customEventOrigin
|
|
174
177
|
? stringPayload(raw.payload, "eventName")
|
|
175
178
|
: null;
|
|
@@ -186,6 +189,8 @@ export function rawEventToSourceSignalEvent(raw, options) {
|
|
|
186
189
|
attributes["cluebase.request.id"] = requestId;
|
|
187
190
|
if (requestSpanId)
|
|
188
191
|
attributes["cluebase.request_span_id"] = requestSpanId;
|
|
192
|
+
if (actionId)
|
|
193
|
+
attributes["cluebase.action.id"] = actionId;
|
|
189
194
|
if (interactionId)
|
|
190
195
|
attributes["cluebase.interaction.id"] = interactionId;
|
|
191
196
|
if (occurrenceKind)
|
|
@@ -264,6 +269,7 @@ export function rawEventToSourceSignalEvent(raw, options) {
|
|
|
264
269
|
trace_id: traceId,
|
|
265
270
|
request_id: requestId,
|
|
266
271
|
request_span_id: requestSpanId,
|
|
272
|
+
action_id: actionId,
|
|
267
273
|
interaction_id: interactionId,
|
|
268
274
|
frontend_release: context.frontend_release,
|
|
269
275
|
backend_release: null,
|
|
@@ -290,6 +296,7 @@ export function rawEventToSourceSignalEvent(raw, options) {
|
|
|
290
296
|
trace_id: traceId,
|
|
291
297
|
request_id: requestId,
|
|
292
298
|
request_span_id: requestSpanId,
|
|
299
|
+
action_id: actionId,
|
|
293
300
|
interaction_id: interactionId,
|
|
294
301
|
}),
|
|
295
302
|
},
|
|
@@ -6,7 +6,7 @@ export type SanitizationAccumulator = {
|
|
|
6
6
|
};
|
|
7
7
|
export declare const isDeniedKey: (key: string, deniedKeys?: readonly string[]) => boolean;
|
|
8
8
|
export declare const sanitizeHeaders: (headers: Record<string, string>, deniedKeys?: string[]) => Record<string, string>;
|
|
9
|
-
export declare const sanitizeObject: (value: unknown, deniedKeys?: string[], summary?: SanitizationAccumulator, pathPrefix?: string) => unknown;
|
|
9
|
+
export declare const sanitizeObject: (value: unknown, deniedKeys?: string[], summary?: SanitizationAccumulator, pathPrefix?: string, maskIdentityPii?: boolean, preserveExplicitTraits?: boolean) => unknown;
|
|
10
10
|
export declare const parseUrlForNetwork: (rawUrl: string) => {
|
|
11
11
|
host: string;
|
|
12
12
|
pathTemplate: string;
|
|
@@ -16,15 +16,4 @@ export declare const parseUrlForNetwork: (rawUrl: string) => {
|
|
|
16
16
|
};
|
|
17
17
|
export declare const maybeParseJson: (value: unknown) => unknown;
|
|
18
18
|
export { detectIdentityPII };
|
|
19
|
-
/**
|
|
20
|
-
* Synchronous deterministic SHA-256 hash for identity values that look
|
|
21
|
-
* like PII. The identity sanitization path must remain synchronous
|
|
22
|
-
* because `identify(userId)` is sync-only by SDK contract.
|
|
23
|
-
*
|
|
24
|
-
* Output format: `cluebase_pii_hashed:<32-hex>`.
|
|
25
|
-
*
|
|
26
|
-
* The algorithm name, the digest length and the prefix are read from the
|
|
27
|
-
* generated policy, not written here, so the three SDKs produce the same
|
|
28
|
-
* token for the same input by construction rather than by assertion.
|
|
29
|
-
*/
|
|
30
19
|
export declare const hashIdentityValue: (value: string) => string;
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
import { sha256 } from "@noble/hashes/sha256";
|
|
2
2
|
import { bytesToHex, utf8ToBytes } from "@noble/hashes/utils";
|
|
3
|
-
// 個人情報の形を見分ける規則も、正本から生成した部品を読みます。
|
|
4
3
|
import { detectIdentityPII, PII_HASH_HEX_LENGTH, PII_HASH_PREFIX, } from "../_generated/identity-pii-policy.js";
|
|
5
|
-
// 落とす項目名・同形異字を畳む表・突き合わせの手順は、正本から生成した部品を
|
|
6
|
-
// 読みます。ここへ写すと受付側とずれます(AGENTS §0.2)。
|
|
7
4
|
import { isDeniedLookupKey, toDeniedLookupKeySet, } from "../_generated/ingest-privacy-tables.js";
|
|
8
5
|
import { toPathTemplate } from "../exporter/path-template.js";
|
|
9
6
|
import { ALWAYS_DENIED_KEYS, MASKED_VALUE, MAX_SANITIZE_DEPTH, } from "../provider/constants.js";
|
|
@@ -25,7 +22,7 @@ export const sanitizeHeaders = (headers, deniedKeys = []) => {
|
|
|
25
22
|
}
|
|
26
23
|
return sanitized;
|
|
27
24
|
};
|
|
28
|
-
export const sanitizeObject = (value, deniedKeys = [], summary, pathPrefix = "") => {
|
|
25
|
+
export const sanitizeObject = (value, deniedKeys = [], summary, pathPrefix = "", maskIdentityPii = true, preserveExplicitTraits = false) => {
|
|
29
26
|
const deniedSet = toDeniedSet(deniedKeys);
|
|
30
27
|
const seen = new WeakSet();
|
|
31
28
|
const walk = (current, depth, path) => {
|
|
@@ -49,7 +46,7 @@ export const sanitizeObject = (value, deniedKeys = [], summary, pathPrefix = "")
|
|
|
49
46
|
seen.add(objectValue);
|
|
50
47
|
const sanitized = {};
|
|
51
48
|
for (const [key, child] of Object.entries(objectValue)) {
|
|
52
|
-
if (isDeniedKeyWithSet(key, deniedSet)) {
|
|
49
|
+
if (!preserveExplicitTraits && isDeniedKeyWithSet(key, deniedSet)) {
|
|
53
50
|
if (summary)
|
|
54
51
|
summary.droppedFieldCount += 1;
|
|
55
52
|
continue;
|
|
@@ -67,13 +64,10 @@ export const sanitizeObject = (value, deniedKeys = [], summary, pathPrefix = "")
|
|
|
67
64
|
if (typeof current === "function" || typeof current === "symbol") {
|
|
68
65
|
return undefined;
|
|
69
66
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
// identity-PII pattern (email / phone / SSN / credit_card with
|
|
75
|
-
// Luhn) with the canonical masked sentinel.
|
|
76
|
-
if (typeof current === "string" && detectIdentityPII(current) !== null) {
|
|
67
|
+
if (maskIdentityPii &&
|
|
68
|
+
!preserveExplicitTraits &&
|
|
69
|
+
typeof current === "string" &&
|
|
70
|
+
detectIdentityPII(current) !== null) {
|
|
77
71
|
if (current === MASKED_VALUE)
|
|
78
72
|
return current;
|
|
79
73
|
if (summary) {
|
|
@@ -146,32 +140,5 @@ export const maybeParseJson = (value) => {
|
|
|
146
140
|
return null;
|
|
147
141
|
}
|
|
148
142
|
};
|
|
149
|
-
// ---------------------------------------------------------------------------
|
|
150
|
-
// Identity-value PII detection for browser payloads.
|
|
151
|
-
//
|
|
152
|
-
// The frontend SDK does not propagate identity via W3C Baggage (browser-side
|
|
153
|
-
// never opens outgoing HTTP to non-Cluebase services), so the leak vector here is
|
|
154
|
-
// the canonical span attribute `cluebase.actor.user_id` getting captured in raw
|
|
155
|
-
// observation storage / facts_json. Hashing PII-shaped identity values
|
|
156
|
-
// prevents that.
|
|
157
|
-
//
|
|
158
|
-
// この面は値の**中**を走査します(裏の配る部品は両端で留めます)。違いは本物で、
|
|
159
|
-
// 意図したものです——**問いが違うからです。** ここは「この文字列が個人情報を
|
|
160
|
-
// 含むか」、裏は「この値そのものが個人情報か」。正本
|
|
161
|
-
// (`identity-pii-policy.v1.json` の面 `text_contains_pii`)に 1 度だけ書かれ、
|
|
162
|
-
// 下の生成物から読みます。**面の名前は生成物の `PII_SURFACE` が名乗ります。**
|
|
163
|
-
// 判定の手順は**正本から出した部品が持ちます**。ここには手順が手で書かれており、
|
|
164
|
-
// 受付側にも同じ手順の 2 本目が手で書かれていました(AGENTS §0.2)。
|
|
165
143
|
export { detectIdentityPII };
|
|
166
|
-
/**
|
|
167
|
-
* Synchronous deterministic SHA-256 hash for identity values that look
|
|
168
|
-
* like PII. The identity sanitization path must remain synchronous
|
|
169
|
-
* because `identify(userId)` is sync-only by SDK contract.
|
|
170
|
-
*
|
|
171
|
-
* Output format: `cluebase_pii_hashed:<32-hex>`.
|
|
172
|
-
*
|
|
173
|
-
* The algorithm name, the digest length and the prefix are read from the
|
|
174
|
-
* generated policy, not written here, so the three SDKs produce the same
|
|
175
|
-
* token for the same input by construction rather than by assertion.
|
|
176
|
-
*/
|
|
177
144
|
export const hashIdentityValue = (value) => `${PII_HASH_PREFIX}${bytesToHex(sha256(utf8ToBytes(value))).slice(0, PII_HASH_HEX_LENGTH)}`;
|
|
@@ -12,6 +12,7 @@ export type BrowserSdkSignalContext = {
|
|
|
12
12
|
trace_id: string;
|
|
13
13
|
request_id: string;
|
|
14
14
|
request_span_id: string;
|
|
15
|
+
action_id: string | null;
|
|
15
16
|
interaction_id: string;
|
|
16
17
|
correlation_id: string | null;
|
|
17
18
|
sdk_collection_mode: SdkCollectionMode;
|
|
@@ -88,6 +89,7 @@ export type BrowserSourceSignalEvent = {
|
|
|
88
89
|
parent_span_id?: string | null;
|
|
89
90
|
request_id: string;
|
|
90
91
|
request_span_id: string;
|
|
92
|
+
action_id: string | null;
|
|
91
93
|
interaction_id: string;
|
|
92
94
|
frontend_release: string | null;
|
|
93
95
|
backend_release: string | null;
|
|
@@ -1,6 +1,3 @@
|
|
|
1
|
-
// 相関のために残す属性名は、正本から生成した部品を読みます。ここには手で書かれて
|
|
2
|
-
// おり、もう 1 か所の写しと 15 語食い違い、`cluebase.session_id` `mcp.session.id` が
|
|
3
|
-
// 欠けていて**落ちていました**(`session` を含むため落とす側に当たる)。
|
|
4
1
|
import { CLUEBASE_CORRELATION_ATTRIBUTE_KEYS } from "../_generated/correlation-attribute-keys.js";
|
|
5
2
|
import { attrString, hrTimeToUnixNano, nanosToIso, parentSpanId, } from "../_generated/otel-span-conversion.js";
|
|
6
3
|
import { EVENT_SOURCE, EVENT_VERSION, SOURCE_SCHEMA_VERSION, } from "../provider/constants.js";
|
|
@@ -59,6 +56,7 @@ function contextRecord(context, overrides) {
|
|
|
59
56
|
trace_id: overrides.trace_id,
|
|
60
57
|
request_id: overrides.request_id,
|
|
61
58
|
request_span_id: overrides.request_span_id,
|
|
59
|
+
action_id: overrides.action_id,
|
|
62
60
|
interaction_id: overrides.interaction_id,
|
|
63
61
|
correlation_id: context.correlation_id,
|
|
64
62
|
sdk_collection_mode: context.sdk_collection_mode,
|
|
@@ -109,6 +107,10 @@ export function spanToSourceSignalEvent(span, options) {
|
|
|
109
107
|
attrString(attributes, "cluebase.request_id") ??
|
|
110
108
|
context.request_id ??
|
|
111
109
|
generateRequestId();
|
|
110
|
+
const actionId = attrString(attributes, "cluebase.action.id") ??
|
|
111
|
+
attrString(attributes, "cluebase.action_id") ??
|
|
112
|
+
context.action_id ??
|
|
113
|
+
null;
|
|
112
114
|
const interactionId = attrString(attributes, "cluebase.interaction.id") ??
|
|
113
115
|
attrString(attributes, "cluebase.interaction_id") ??
|
|
114
116
|
context.interaction_id ??
|
|
@@ -139,6 +141,7 @@ export function spanToSourceSignalEvent(span, options) {
|
|
|
139
141
|
parent_span_id: parentId,
|
|
140
142
|
request_id: requestId,
|
|
141
143
|
request_span_id: requestSpanId,
|
|
144
|
+
action_id: actionId,
|
|
142
145
|
interaction_id: interactionId,
|
|
143
146
|
frontend_release: context.frontend_release,
|
|
144
147
|
backend_release: null,
|
|
@@ -167,6 +170,7 @@ export function spanToSourceSignalEvent(span, options) {
|
|
|
167
170
|
trace_id: traceId,
|
|
168
171
|
request_id: requestId,
|
|
169
172
|
request_span_id: requestSpanId,
|
|
173
|
+
action_id: actionId,
|
|
170
174
|
interaction_id: interactionId,
|
|
171
175
|
}),
|
|
172
176
|
},
|
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 文字列を短い印にたたむ。
|
|
3
|
-
*
|
|
4
|
-
* 中身そのものは残さない(項目の並びや入力欄の値は、そのままでは送れない)。
|
|
5
|
-
* 同じ入力なら必ず同じ印になり、印から元へは戻らない。
|
|
6
|
-
* FNV-1a の 32 bit 版。**衝突しない保証は無い**ので、突合の鍵ではなく
|
|
7
|
-
* 「同じか違うか」を見るための印としてのみ使う。
|
|
8
|
-
*/
|
|
9
1
|
export const fingerprintText = (value) => {
|
|
10
2
|
let hash = 0x811c9dc5;
|
|
11
3
|
for (let index = 0; index < value.length; index += 1) {
|
package/dist/index.d.ts
CHANGED
|
@@ -2,10 +2,6 @@ import type { InternalMetricsSnapshot } from "./internal/metrics.js";
|
|
|
2
2
|
import { createCluebaseBrowserSdk } from "./provider/sdk.js";
|
|
3
3
|
import type { CluebaseBrowserSdk, CluebaseConsentState, CluebaseGroupTraits, CluebaseGroupType, CluebaseInitOptions, CluebaseSubjectTraits, CluebaseTrackMetrics, CluebaseTrackProperties } from "./provider/types.js";
|
|
4
4
|
declare const cluebase: {
|
|
5
|
-
/**
|
|
6
|
-
* SDK を初期化する。 `endpoint` / `projectKey` は必須で、 欠けていれば送信を
|
|
7
|
-
* 黙って捨てずに throw する。
|
|
8
|
-
*/
|
|
9
5
|
init(options: CluebaseInitOptions): void;
|
|
10
6
|
identify(userId: string, traits?: CluebaseSubjectTraits): void;
|
|
11
7
|
group(groupType: CluebaseGroupType, groupKey: string, traits?: CluebaseGroupTraits): void;
|
package/dist/index.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
import { parseSendableUrl } from "./provider/config.js";
|
|
2
2
|
import { createCluebaseBrowserSdk } from "./provider/sdk.js";
|
|
3
|
-
// SDK は host application 内で singleton として動く。
|
|
4
|
-
// 同じ tab で複数回 import されても 1 instance に統合する。
|
|
5
3
|
const GLOBAL_SDK_KEY = "__cluebase_frontend_sdk_singleton__";
|
|
6
4
|
const globalWithCluebaseSdk = globalThis;
|
|
7
5
|
const existingSdk = globalWithCluebaseSdk[GLOBAL_SDK_KEY];
|
|
@@ -18,7 +16,6 @@ const reportPublicSdkError = (operation) => {
|
|
|
18
16
|
}
|
|
19
17
|
}
|
|
20
18
|
catch {
|
|
21
|
-
// Cluebase は host application を絶対に失敗させない。
|
|
22
19
|
}
|
|
23
20
|
};
|
|
24
21
|
const safeCall = (operation, callback) => {
|
|
@@ -37,10 +34,6 @@ const safeAsyncCall = async (operation, callback) => {
|
|
|
37
34
|
reportPublicSdkError(operation);
|
|
38
35
|
}
|
|
39
36
|
};
|
|
40
|
-
// `privacy.allowedValuePaths` used to let the caller name the request/response
|
|
41
|
-
// fields that may leave the browser in clear text. That choice now belongs to
|
|
42
|
-
// the Cluebase privacy screen and reaches the SDK through the delivered privacy
|
|
43
|
-
// policy, so the option has nothing left to apply to.
|
|
44
37
|
const REMOVED_PRIVACY_OPTIONS = {
|
|
45
38
|
allowedValuePaths: "cluebase.init: 'privacy.allowedValuePaths' was removed in " +
|
|
46
39
|
"@genn-inc/cluebase-frontend-sdk 0.0.1. The fields allowed to leave the browser in " +
|
|
@@ -49,22 +42,7 @@ const REMOVED_PRIVACY_OPTIONS = {
|
|
|
49
42
|
"and select those fields in the Cluebase privacy screen instead. Until then " +
|
|
50
43
|
"every value stays masked.",
|
|
51
44
|
};
|
|
52
|
-
const REQUIRED_INIT_OPTIONS = [
|
|
53
|
-
"endpoint",
|
|
54
|
-
"projectKey",
|
|
55
|
-
];
|
|
56
|
-
/**
|
|
57
|
-
* Raise on a setup mistake instead of collecting nothing and warning once.
|
|
58
|
-
*
|
|
59
|
-
* This is the one place `cluebase.init` is allowed to throw. The `cluebase.*`
|
|
60
|
-
* never-propagate contract exists so a telemetry fault cannot break a page the
|
|
61
|
-
* visitor is using; it is not there to hide a setting that quietly stopped
|
|
62
|
-
* applying, nor a key the caller forgot. Both mistakes come from the build's
|
|
63
|
-
* own configuration, so they surface on the developer's first page load rather
|
|
64
|
-
* than on a visitor's — and left silent they are invisible afterwards: the page
|
|
65
|
-
* renders, `getDiagnostics()` reports an open lifecycle with no errors, and not
|
|
66
|
-
* one event is ever delivered.
|
|
67
|
-
*/
|
|
45
|
+
const REQUIRED_INIT_OPTIONS = ["projectKey"];
|
|
68
46
|
const validateInitOptions = (options) => {
|
|
69
47
|
const privacy = options.privacy ?? {};
|
|
70
48
|
for (const [name, message] of Object.entries(REMOVED_PRIVACY_OPTIONS)) {
|
|
@@ -72,8 +50,6 @@ const validateInitOptions = (options) => {
|
|
|
72
50
|
throw new Error(message);
|
|
73
51
|
}
|
|
74
52
|
}
|
|
75
|
-
// Anything that is not a string reads as absent, so a stray `undefined` from
|
|
76
|
-
// the bundler's env replacement cannot become the literal "undefined" endpoint.
|
|
77
53
|
const missing = REQUIRED_INIT_OPTIONS.filter((name) => {
|
|
78
54
|
const value = options[name];
|
|
79
55
|
return typeof value !== "string" || value.trim() === "";
|
|
@@ -83,12 +59,15 @@ const validateInitOptions = (options) => {
|
|
|
83
59
|
`${missing.length === 1 ? "is" : "are"} required and must be a non-empty ` +
|
|
84
60
|
"string. Cluebase collects nothing until every required option is set. Pass " +
|
|
85
61
|
"them from your environment, for example " +
|
|
86
|
-
"
|
|
87
|
-
"
|
|
62
|
+
"projectKey=NEXT_PUBLIC_CLUEBASE_PROJECT_KEY. The endpoint is optional " +
|
|
63
|
+
"and defaults to the hosted Cluebase API.");
|
|
88
64
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
65
|
+
if (options.endpoint !== undefined &&
|
|
66
|
+
typeof options.endpoint !== "string") {
|
|
67
|
+
throw new Error("cluebase.init: 'endpoint' must be a string when provided. Omit it to " +
|
|
68
|
+
"use the hosted Cluebase endpoint.");
|
|
69
|
+
}
|
|
70
|
+
if (options.endpoint?.trim() && parseSendableUrl(options.endpoint) === null) {
|
|
92
71
|
throw new Error(`cluebase.init: 'endpoint' must be an absolute http:// or https:// URL, but ` +
|
|
93
72
|
`received ${JSON.stringify(options.endpoint)}. An endpoint the SDK cannot ` +
|
|
94
73
|
"send to loses every event just as silently as one that was never passed. " +
|
|
@@ -96,15 +75,8 @@ const validateInitOptions = (options) => {
|
|
|
96
75
|
"endpoint=NEXT_PUBLIC_CLUEBASE_API_BASE_URL.");
|
|
97
76
|
}
|
|
98
77
|
};
|
|
99
|
-
// Public API — namespace `cluebase` の 8 method
|
|
100
78
|
const cluebase = {
|
|
101
|
-
/**
|
|
102
|
-
* SDK を初期化する。 `endpoint` / `projectKey` は必須で、 欠けていれば送信を
|
|
103
|
-
* 黙って捨てずに throw する。
|
|
104
|
-
*/
|
|
105
79
|
init(options) {
|
|
106
|
-
// Thrown before `safeCall` so the caller actually sees it; see
|
|
107
|
-
// `validateInitOptions`.
|
|
108
80
|
validateInitOptions(options);
|
|
109
81
|
safeCall("init", () => {
|
|
110
82
|
internalSdk.init(options);
|
|
@@ -142,7 +114,6 @@ const cluebase = {
|
|
|
142
114
|
async close() {
|
|
143
115
|
await safeAsyncCall("close", () => internalSdk.close());
|
|
144
116
|
},
|
|
145
|
-
// 内部 metrics 取得 (= 顧客 debug 用、 production では通常不要)
|
|
146
117
|
getInternalMetrics() {
|
|
147
118
|
return internalSdk.getInternalMetrics();
|
|
148
119
|
},
|
|
@@ -150,7 +121,6 @@ const cluebase = {
|
|
|
150
121
|
return internalSdk.getDiagnostics();
|
|
151
122
|
},
|
|
152
123
|
};
|
|
153
|
-
// OTel-core exporter surface for advanced host integrations.
|
|
154
124
|
export { EXPORT_RESULT_FAILED, EXPORT_RESULT_SUCCESS, ObservationSourceEventExporter, } from "./exporter/observation-source-event-exporter.js";
|
|
155
125
|
export { attachNextRouterIntegration } from "./integrations/next-router.js";
|
|
156
126
|
export { attachReactRouterIntegration } from "./integrations/react-router.js";
|