@vtex/faststore-plugin-buyer-portal 2.0.19 → 2.0.20
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/CHANGELOG.md +8 -1
- package/package.json +1 -1
- package/specs/analytics-actor-type.md +243 -0
- package/src/features/shared/hooks/analytics/useAnalytics.ts +1 -1
- package/src/features/shared/services/logger/analytics/__tests__/deriveActorType.test.ts +30 -0
- package/src/features/shared/services/logger/analytics/__tests__/mergeActorTypeIntoEvent.test.ts +83 -0
- package/src/features/shared/services/logger/analytics/analytics.ts +2 -24
- package/src/features/shared/services/logger/analytics/deriveActorType.ts +24 -0
- package/src/features/shared/services/logger/analytics/mergeActorTypeIntoEvent.ts +29 -0
- package/src/features/shared/services/logger/analytics/types.ts +5 -0
- package/src/features/shared/services/logger/analytics/useDataIngestionApi.ts +32 -0
- package/src/features/shared/utils/constants.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.0.20] - 2026-08-12
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Analytics events now include a derived `metadata.actor_type` (`internal` | `external`) based on the logged-in user's email domain (`@vtex.com` → internal), with no email or identity fields in the payload ([B2BTEAM-3639](https://vtex-dev.atlassian.net/browse/B2BTEAM-3639))
|
|
15
|
+
|
|
10
16
|
## [2.0.19] - 2026-08-04
|
|
11
17
|
|
|
12
18
|
### Changed
|
|
@@ -821,7 +827,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
821
827
|
- Add CHANGELOG file
|
|
822
828
|
- Add README file
|
|
823
829
|
|
|
824
|
-
[unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.
|
|
830
|
+
[unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.20...HEAD
|
|
825
831
|
[1.3.55]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.54...v1.3.55
|
|
826
832
|
[1.3.54]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.53...v1.3.54
|
|
827
833
|
[1.3.53]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.52...v1.3.53
|
|
@@ -919,6 +925,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
919
925
|
[2.0.10]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.9...v2.0.10
|
|
920
926
|
[2.0.9]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/2.0.9
|
|
921
927
|
|
|
928
|
+
[2.0.20]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.19...v2.0.20
|
|
922
929
|
[2.0.19]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.18...v2.0.19
|
|
923
930
|
[2.0.18]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.17...v2.0.18
|
|
924
931
|
[2.0.17]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v2.0.16...v2.0.17
|
package/package.json
CHANGED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
# Analytics Actor Type (Internal vs External)
|
|
2
|
+
|
|
3
|
+
> **Status**: Done
|
|
4
|
+
> **Created**: 2026-07-28
|
|
5
|
+
> **Jira**: [B2BTEAM-3639](https://vtex-dev.atlassian.net/browse/B2BTEAM-3639)
|
|
6
|
+
> **Epic**: [B2BTEAM-3596](https://vtex-dev.atlassian.net/browse/B2BTEAM-3596) — Melhoria 1
|
|
7
|
+
> **Sibling task**: [B2BTEAM-3640](https://vtex-dev.atlassian.net/browse/B2BTEAM-3640) (Redshift column + QuickSight segmentation)
|
|
8
|
+
|
|
9
|
+
## 1. Business Context
|
|
10
|
+
|
|
11
|
+
### Problem Statement
|
|
12
|
+
|
|
13
|
+
Organization Account already emits usage analytics (create/edit/delete, timing, errors) via `useAnalytics` into the VTEX Data Ingestion API, Redshift, and QuickSight. Those numbers mix real customer usage with traffic from VTEX employees testing and supporting accounts.
|
|
14
|
+
|
|
15
|
+
PMs cannot filter “real usage” without a reliable, PII-free signal on each event that says whether the actor was internal VTEX or external.
|
|
16
|
+
|
|
17
|
+
### Goals
|
|
18
|
+
|
|
19
|
+
1. Every Org Account analytics event includes a derived `actor_type` of `internal` or `external`.
|
|
20
|
+
2. Usage metrics can exclude VTEX traffic without dropping error analysis options (filtering stays at the consumer; emission always sends the field).
|
|
21
|
+
3. Zero PII in the analytics envelope: never send email, login, user id, or other identity — only the derived enum.
|
|
22
|
+
|
|
23
|
+
### User Stories
|
|
24
|
+
|
|
25
|
+
#### US-1: Emit actor_type on every analytics event
|
|
26
|
+
|
|
27
|
+
- **Story**: As a PM analyzing Org Account usage, I want every event tagged as internal or external, so I can segment real customer usage from VTEX traffic.
|
|
28
|
+
- **Acceptance Criteria**:
|
|
29
|
+
- **Given** a logged-in user whose email domain is `vtex.com`, **when** any analytics event is emitted through `useAnalytics` / `useDataIngestionApi`, **then** the payload includes `actor_type: "internal"`.
|
|
30
|
+
- **Given** a logged-in user whose email domain is not `vtex.com`, **when** any analytics event is emitted, **then** the payload includes `actor_type: "external"`.
|
|
31
|
+
- **Given** any successful emission path (`trackEvent`, `trackError`, `trackEntityCreated`, `trackEntityEdited`, `trackEntityCreateError`, `trackEntityEditError`), **when** the event is sent, **then** `actor_type` is present on the envelope (not only inside caller-supplied metadata).
|
|
32
|
+
|
|
33
|
+
#### US-2: Never send identity or email in the event
|
|
34
|
+
|
|
35
|
+
- **Story**: As a platform owner, I want the analytics envelope to stay free of PII, so we do not leak user identity into Redshift / QuickSight / Slack summaries.
|
|
36
|
+
- **Acceptance Criteria**:
|
|
37
|
+
- **Given** a user with email `alice@vtex.com`, **when** an event is emitted, **then** the JSON body does not contain that email, login, `userId`, or session `sub`.
|
|
38
|
+
- **Given** derivation needs the email in memory, **when** building the payload, **then** only the enum `actor_type` is attached to the outgoing event.
|
|
39
|
+
|
|
40
|
+
#### US-3: Safe default when identity is unavailable
|
|
41
|
+
|
|
42
|
+
- **Story**: As an engineer, I want a deterministic fallback when email is missing, so every event still carries `actor_type` and the pipeline never breaks.
|
|
43
|
+
- **Acceptance Criteria**:
|
|
44
|
+
- **Given** `currentUser` is null or `email` is empty/missing, **when** an event is emitted, **then** `actor_type` is `"external"`.
|
|
45
|
+
- **Given** derivation throws or email is malformed (no `@`), **when** an event is emitted, **then** `actor_type` is `"external"` and emission still succeeds.
|
|
46
|
+
|
|
47
|
+
### Key Scenarios
|
|
48
|
+
|
|
49
|
+
| Scenario | Pre-conditions | Steps | Expected Result |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| Happy path — VTEX employee | `currentUser.email` is `rodrigo.tavares@vtex.com` | User creates a budget; `trackEntityCreated` runs | Event has `actor_type: "internal"`; no email in payload |
|
|
52
|
+
| Happy path — customer | `currentUser.email` is `buyer@acme.com` | User edits an address; `trackEntityEdited` runs | Event has `actor_type: "external"` |
|
|
53
|
+
| Error case — Users API failed in loader | `currentUser` is `null` (e.g. `getUserByIdService` returned null) | User still triggers an analytics call from a mounted component | Event has `actor_type: "external"`; send does not throw |
|
|
54
|
+
| Edge case — username login, email from API | Session `sub` is `everton.ataidetestdev` (not an email); Users API returns `email: "everton@vtex.com"` | Any event is emitted | Classification uses API email → `internal`; session `sub` is never sent |
|
|
55
|
+
| Edge case — case / plus addressing | Email is `Name+qa@VTEX.COM` | Event emitted | Domain match is case-insensitive → `internal` |
|
|
56
|
+
|
|
57
|
+
### Functional Requirements
|
|
58
|
+
|
|
59
|
+
1. Derive `actor_type` at emission time from the logged-in user's email domain.
|
|
60
|
+
2. Classification rule: domain equals `vtex.com` (case-insensitive) → `internal`; otherwise → `external`.
|
|
61
|
+
3. Attach `actor_type` on every event sent through the shared analytics send path so callers do not need to pass it.
|
|
62
|
+
4. Do not attach email, login, userId, session `sub`, or other identity fields to the event.
|
|
63
|
+
5. Remain backward compatible with the existing schemaless ingestion pipeline (additive field only).
|
|
64
|
+
|
|
65
|
+
### Non-Functional Requirements
|
|
66
|
+
|
|
67
|
+
1. **Privacy**: envelope stays zero-PII for identity; derivation is local/ephemeral.
|
|
68
|
+
2. **Reliability**: missing user context must not block or fail analytics emission.
|
|
69
|
+
3. **Performance**: derivation is O(1) string work; no extra network calls at emit time.
|
|
70
|
+
4. **Testability**: pure domain classifier is unit-tested; send path asserts field presence and absence of PII.
|
|
71
|
+
|
|
72
|
+
### Out of Scope
|
|
73
|
+
|
|
74
|
+
- Exposing `actor_type` as a Redshift column or QuickSight filter (sibling [B2BTEAM-3640](https://vtex-dev.atlassian.net/browse/B2BTEAM-3640)).
|
|
75
|
+
- Melhoria 2 (`error_type` / stripping raw `error_message` / `error_stack`).
|
|
76
|
+
- Rewriting the telemetry pipeline, DAG, or Slack weekly summary.
|
|
77
|
+
- Classifying by session `sub` alone, account name, IP, or workspace.
|
|
78
|
+
- Matching sibling domains (e.g. `vtex.com.br`) or subdomains (e.g. `corp.vtex.com`) — only exact `vtex.com`.
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
## 2. Arch Decisions
|
|
83
|
+
|
|
84
|
+
### Proposed Solution
|
|
85
|
+
|
|
86
|
+
Enrich the shared analytics send path so every event automatically receives `actor_type`, derived from `currentUser.email` already available in `BuyerPortalContext`.
|
|
87
|
+
|
|
88
|
+
**Identity source (important):** the auth session JWT does **not** carry email. Example session claims:
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"sub": "everton.ataidetestdev",
|
|
93
|
+
"userId": "dd801513-ae2f-4ff8-b6de-dd3a2159a895",
|
|
94
|
+
"customerId": "…",
|
|
95
|
+
"unitId": "…",
|
|
96
|
+
"account": "b2bfaststoredev",
|
|
97
|
+
"type": "user"
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
`sub` is a login/username, not an email. Email is loaded server-side in page loaders via `getUserByIdService` → Users API (`usersClient.getUserById`), then passed into `BuyerPortalProvider` as `currentUser.email`. Classification must use that email, never the session `sub`.
|
|
102
|
+
|
|
103
|
+
```mermaid
|
|
104
|
+
flowchart LR
|
|
105
|
+
Cookie["Auth cookie JWT\nuserId, sub, …"] --> Loader["Page loader\nwithAuthLoader"]
|
|
106
|
+
Loader --> UsersAPI["getUserByIdService\nUsers API"]
|
|
107
|
+
UsersAPI --> Ctx["BuyerPortalContext\ncurrentUser.email"]
|
|
108
|
+
Ctx --> Derive["deriveActorType(email)\ninternal | external"]
|
|
109
|
+
Derive --> Send["useDataIngestionApi.sendEvent\n+ actor_type"]
|
|
110
|
+
Send --> Ingest["VTEX schemaless-events"]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### Architecture Overview
|
|
114
|
+
|
|
115
|
+
| Layer | Responsibility |
|
|
116
|
+
|---|---|
|
|
117
|
+
| `deriveActorType(email?)` | Pure helper: parse domain, return `internal` \| `external` |
|
|
118
|
+
| `useDataIngestionApi` | Read `currentUser?.email` from `BuyerPortalContext` (optional context, like `useLogger`); merge `actor_type` into every `sendEvent` payload at top level alongside `account`, `locale`, `device`, `production` |
|
|
119
|
+
| `useAnalytics` | Unchanged call sites; inherits enrichment via `sendEvent` |
|
|
120
|
+
| Callers | No changes required to pass `actor_type` |
|
|
121
|
+
|
|
122
|
+
### Alternatives Considered
|
|
123
|
+
|
|
124
|
+
| Alternative | Pros | Cons | Verdict |
|
|
125
|
+
|---|---|---|---|
|
|
126
|
+
| Derive in each feature caller | Explicit per event | Easy to miss; duplicates logic | Rejected |
|
|
127
|
+
| Derive only inside `useAnalytics.withDefaults` | Covers most UI tracking | Anything calling `useDataIngestionApi` / `dataIngestionApi` directly could skip it; harder to guarantee “every event” | Rejected as sole approach |
|
|
128
|
+
| Classify from session `sub` | No dependency on Users API | `sub` is often not an email (e.g. `everton.ataidetestdev`); false `external` for VTEX staff | Rejected |
|
|
129
|
+
| Put `actor_type` only inside `metadata` | Minimal type change | Sibling Redshift task wants a first-class column; top-level matches existing common fields | Rejected |
|
|
130
|
+
| Centralize in `useDataIngestionApi` + pure helper | Single choke point; additive; testable | Hook must tolerate missing React context | **Accepted** |
|
|
131
|
+
|
|
132
|
+
### Risks & Mitigations
|
|
133
|
+
|
|
134
|
+
| Risk | Impact | Likelihood | Mitigation |
|
|
135
|
+
|---|---|---|---|
|
|
136
|
+
| `currentUser` null when analytics fires | Med (VTEX traffic mislabeled as external) | Med | Default `external`; ensure loaders keep loading user where events fire; document gap |
|
|
137
|
+
| VTEX staff using non-`@vtex.com` email | Med (internal traffic still pollutes usage) | Low | Document rule; extend domain list only via explicit follow-up |
|
|
138
|
+
| Customer email on `vtex.com` (unlikely) | Low | Low | Accept false `internal`; rule is domain-based by product decision |
|
|
139
|
+
| Accidental PII leak via spread of user object | High | Low | Unit tests asserting outbound payload keys; helper never returns email |
|
|
140
|
+
|
|
141
|
+
### Key Decisions
|
|
142
|
+
|
|
143
|
+
#### Decision 1: Classification rule = exact `@vtex.com` domain
|
|
144
|
+
|
|
145
|
+
- **Status**: Accepted
|
|
146
|
+
- **Context**: Need a simple, privacy-safe heuristic for “VTEX employee”.
|
|
147
|
+
- **Decision**: `internal` iff the email’s domain equals `vtex.com` (case-insensitive). Plus-tags (`user+tag@vtex.com`) still match. Domains like `vtex.com.br` or `*.vtex.com` are **not** internal in this iteration.
|
|
148
|
+
- **Consequences**: Simple to implement and document; may miss regional VTEX domains until explicitly expanded.
|
|
149
|
+
|
|
150
|
+
#### Decision 2: Fallback = `external` when email unavailable
|
|
151
|
+
|
|
152
|
+
- **Status**: Accepted
|
|
153
|
+
- **Context**: Ticket enum is only `internal \| external`; every event must carry the field.
|
|
154
|
+
- **Decision**: Missing/invalid email → `external`.
|
|
155
|
+
- **Consequences**: Some VTEX traffic without a loaded user profile remains in “external” usage until context is present; safer than inventing a third enum value for Melhoria 1.
|
|
156
|
+
|
|
157
|
+
#### Decision 3: Identity source = `currentUser.email` from BuyerPortalContext
|
|
158
|
+
|
|
159
|
+
- **Status**: Accepted
|
|
160
|
+
- **Context**: Session JWT has `userId` / `sub` but not email; email already exists on `UserData` after loader fetch.
|
|
161
|
+
- **Decision**: Read `currentUser?.email` from context at send time. Do not decode the auth cookie on the client for classification. Do not use `login` / `sub` unless it is later proven to be an email and product asks for it — out of scope for v1 (email-only).
|
|
162
|
+
- **Consequences**: Correct for the common path where pages load the user; no extra network at emit time.
|
|
163
|
+
|
|
164
|
+
#### Decision 4: Place `actor_type` at top level of the event envelope
|
|
165
|
+
|
|
166
|
+
- **Status**: Accepted
|
|
167
|
+
- **Context**: Sibling task needs a Redshift column; common context fields (`account`, `locale`, `device`, `production`) are already top-level.
|
|
168
|
+
- **Decision**: Set `actor_type` as a top-level field on the object passed to `dataIngestionApi`, not buried only under `metadata`.
|
|
169
|
+
- **Consequences**: Easier DAG mapping; additive and backward compatible for schemaless ingestion.
|
|
170
|
+
|
|
171
|
+
### Implementation Plan
|
|
172
|
+
|
|
173
|
+
1. Add `ActorType` type and pure `deriveActorType(email?: string | null): ActorType` under shared analytics (or shared utils) with unit tests.
|
|
174
|
+
2. Extend `DataIngestionApiFields` / send path types with optional/required `actor_type`.
|
|
175
|
+
3. Update `useDataIngestionApi` to read `BuyerPortalContext` (gracefully if undefined) and always set `actor_type` on `sendEvent`.
|
|
176
|
+
4. Add tests that mock context email and assert outbound payload contains `actor_type` and does not contain email/login/userId.
|
|
177
|
+
5. Manual smoke on a host store: one `@vtex.com` user and one external user; confirm events in the analytics pipeline / network tab.
|
|
178
|
+
6. Hand off to [B2BTEAM-3640](https://vtex-dev.atlassian.net/browse/B2BTEAM-3640) for Redshift + QuickSight.
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## 3. Technical Contract
|
|
183
|
+
|
|
184
|
+
### Data Models
|
|
185
|
+
|
|
186
|
+
```ts
|
|
187
|
+
type ActorType = "internal" | "external";
|
|
188
|
+
|
|
189
|
+
/** Pure classifier — never logs or returns the input email. */
|
|
190
|
+
function deriveActorType(email?: string | null): ActorType;
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Rules for `deriveActorType`:**
|
|
194
|
+
|
|
195
|
+
1. Trim and lowercase the email.
|
|
196
|
+
2. If empty or no `@`, return `"external"`.
|
|
197
|
+
3. Take the substring after the last `@` as the domain.
|
|
198
|
+
4. If domain === `"vtex.com"`, return `"internal"`; else `"external"`.
|
|
199
|
+
|
|
200
|
+
**Event envelope (additive):**
|
|
201
|
+
|
|
202
|
+
| Field | Type | Required on emit | Notes |
|
|
203
|
+
|---|---|---|---|
|
|
204
|
+
| `actor_type` | `"internal" \| "external"` | Yes | Derived; top-level |
|
|
205
|
+
| `email` / `login` / `userId` / `sub` | — | **Must not appear** | PII / identity ban |
|
|
206
|
+
|
|
207
|
+
Existing fields (`name`, `account`, `locale`, `production`, `device`, `event_name`, `event_category`, `metadata`, …) unchanged.
|
|
208
|
+
|
|
209
|
+
### Interfaces
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
// analytics types — additive
|
|
213
|
+
interface DataIngestionApiFields {
|
|
214
|
+
// …existing fields…
|
|
215
|
+
actor_type?: ActorType; // set by send path; callers need not pass
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// useDataIngestionApi sendEvent behavior
|
|
219
|
+
sendEvent(fields: DataIngestionApiFields): void
|
|
220
|
+
// always merges: { ...commonFields, ...fields, actor_type: deriveActorType(currentUser?.email) }
|
|
221
|
+
// caller-supplied actor_type must not override the derived value (server of truth = derivation)
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
`useAnalytics` public API stays the same; no new required options on `UseAnalyticsConfig`.
|
|
225
|
+
|
|
226
|
+
### Integration Points
|
|
227
|
+
|
|
228
|
+
| System | Interaction |
|
|
229
|
+
|---|---|
|
|
230
|
+
| Auth cookie / session JWT | Provides `userId` only (indirectly); **not** used for domain classification |
|
|
231
|
+
| Users API via `getUserByIdService` | Source of `currentUser.email` in loaders |
|
|
232
|
+
| `BuyerPortalContext` | Runtime source of email for derivation in the client send hook |
|
|
233
|
+
| VTEX Data Ingestion (`schemaless-events`) | Receives additive `actor_type`; no schema migration in this task |
|
|
234
|
+
| Redshift / QuickSight | Consumer of the field in sibling task B2BTEAM-3640 |
|
|
235
|
+
|
|
236
|
+
### Invariants & Constraints
|
|
237
|
+
|
|
238
|
+
1. Every event leaving `useDataIngestionApi.sendEvent` includes `actor_type`.
|
|
239
|
+
2. Outgoing analytics JSON must never include email, login, userId, or session `sub` as a result of this feature.
|
|
240
|
+
3. Derivation does not perform network I/O.
|
|
241
|
+
4. Callers cannot force a conflicting `actor_type` that bypasses derivation (derived value wins).
|
|
242
|
+
5. Domains other than exact `vtex.com` are always `external` in this version.
|
|
243
|
+
6. This feature does not change event names, categories, or existing metadata shapes beyond the additive field.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
|
|
3
|
-
import { useDataIngestionApi } from "../../services/logger/analytics/
|
|
3
|
+
import { useDataIngestionApi } from "../../services/logger/analytics/useDataIngestionApi";
|
|
4
4
|
|
|
5
5
|
import type {
|
|
6
6
|
AnalyticsTimer,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { deriveActorType } from "../deriveActorType";
|
|
4
|
+
|
|
5
|
+
describe("deriveActorType", () => {
|
|
6
|
+
it("returns internal for exact @vtex.com domain", () => {
|
|
7
|
+
expect(deriveActorType("rodrigo.tavares@vtex.com")).toBe("internal");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns internal for case-insensitive and plus-tagged vtex.com emails", () => {
|
|
11
|
+
expect(deriveActorType("Name+qa@VTEX.COM")).toBe("internal");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("returns external for non-vtex.com domains", () => {
|
|
15
|
+
expect(deriveActorType("buyer@acme.com")).toBe("external");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns external for vtex.com.br and subdomain lookalikes", () => {
|
|
19
|
+
expect(deriveActorType("user@vtex.com.br")).toBe("external");
|
|
20
|
+
expect(deriveActorType("user@corp.vtex.com")).toBe("external");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("returns external when email is missing, empty, or malformed", () => {
|
|
24
|
+
expect(deriveActorType(undefined)).toBe("external");
|
|
25
|
+
expect(deriveActorType(null)).toBe("external");
|
|
26
|
+
expect(deriveActorType("")).toBe("external");
|
|
27
|
+
expect(deriveActorType(" ")).toBe("external");
|
|
28
|
+
expect(deriveActorType("not-an-email")).toBe("external");
|
|
29
|
+
});
|
|
30
|
+
});
|
package/src/features/shared/services/logger/analytics/__tests__/mergeActorTypeIntoEvent.test.ts
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import * as deriveActorTypeModule from "../deriveActorType";
|
|
4
|
+
import { mergeActorTypeIntoEvent } from "../mergeActorTypeIntoEvent";
|
|
5
|
+
|
|
6
|
+
import type { DataIngestionApiFields } from "../types";
|
|
7
|
+
|
|
8
|
+
describe("mergeActorTypeIntoEvent", () => {
|
|
9
|
+
it("attaches internal actor_type into metadata from a vtex.com email", () => {
|
|
10
|
+
const result = mergeActorTypeIntoEvent(
|
|
11
|
+
{ event_name: "budget_created" },
|
|
12
|
+
"alice@vtex.com"
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
expect(result.metadata?.actor_type).toBe("internal");
|
|
16
|
+
expect(result).not.toHaveProperty("actor_type");
|
|
17
|
+
expect(result).not.toHaveProperty("email");
|
|
18
|
+
expect(result).not.toHaveProperty("login");
|
|
19
|
+
expect(result).not.toHaveProperty("userId");
|
|
20
|
+
expect(result).not.toHaveProperty("sub");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("attaches external actor_type into metadata from a non-vtex email", () => {
|
|
24
|
+
const result = mergeActorTypeIntoEvent(
|
|
25
|
+
{ event_name: "budget_created" },
|
|
26
|
+
"buyer@acme.com"
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(result.metadata?.actor_type).toBe("external");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("defaults to external when email is unavailable", () => {
|
|
33
|
+
expect(
|
|
34
|
+
mergeActorTypeIntoEvent({ event_name: "budget_created" }, null).metadata
|
|
35
|
+
?.actor_type
|
|
36
|
+
).toBe("external");
|
|
37
|
+
expect(
|
|
38
|
+
mergeActorTypeIntoEvent({ event_name: "budget_created" }, undefined)
|
|
39
|
+
.metadata?.actor_type
|
|
40
|
+
).toBe("external");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("preserves other metadata fields while attaching actor_type", () => {
|
|
44
|
+
const fields: DataIngestionApiFields = {
|
|
45
|
+
event_name: "budget_created",
|
|
46
|
+
metadata: { foo: "bar" },
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const result = mergeActorTypeIntoEvent(fields, "buyer@acme.com");
|
|
50
|
+
|
|
51
|
+
expect(result.metadata).toEqual({ foo: "bar", actor_type: "external" });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("falls back to external when deriveActorType throws", () => {
|
|
55
|
+
vi.spyOn(deriveActorTypeModule, "deriveActorType").mockImplementation(
|
|
56
|
+
() => {
|
|
57
|
+
throw new Error("unexpected");
|
|
58
|
+
}
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
expect(
|
|
62
|
+
mergeActorTypeIntoEvent(
|
|
63
|
+
{ event_name: "budget_created" },
|
|
64
|
+
"alice@vtex.com"
|
|
65
|
+
).metadata?.actor_type
|
|
66
|
+
).toBe("external");
|
|
67
|
+
|
|
68
|
+
vi.restoreAllMocks();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("does not put the source email into the event payload", () => {
|
|
72
|
+
const email = "alice@vtex.com";
|
|
73
|
+
const result = mergeActorTypeIntoEvent(
|
|
74
|
+
{ event_name: "address_edited", event_category: "edition" },
|
|
75
|
+
email
|
|
76
|
+
);
|
|
77
|
+
const serialized = JSON.stringify(result);
|
|
78
|
+
|
|
79
|
+
expect(serialized).not.toContain(email);
|
|
80
|
+
expect(serialized).not.toContain("alice");
|
|
81
|
+
expect(result.metadata?.actor_type).toBe("internal");
|
|
82
|
+
});
|
|
83
|
+
});
|
|
@@ -2,7 +2,7 @@ import storeConfig from "discovery.config";
|
|
|
2
2
|
|
|
3
3
|
import { isDevelopment } from "../../../utils/environment";
|
|
4
4
|
|
|
5
|
-
import type {
|
|
5
|
+
import type { DataIngestionApiParams } from "./types";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* VTEX Data Ingestion API for Analytics
|
|
@@ -50,7 +50,7 @@ export async function dataIngestionApi(
|
|
|
50
50
|
/**
|
|
51
51
|
* Get common analytics context
|
|
52
52
|
*/
|
|
53
|
-
function getCommonAnalyticsContext() {
|
|
53
|
+
export function getCommonAnalyticsContext() {
|
|
54
54
|
const account = storeConfig?.api?.storeId || "unknown";
|
|
55
55
|
const locale =
|
|
56
56
|
typeof window !== "undefined"
|
|
@@ -77,25 +77,3 @@ function getCommonAnalyticsContext() {
|
|
|
77
77
|
device,
|
|
78
78
|
};
|
|
79
79
|
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Hook to send analytics events
|
|
83
|
-
* This is a simple version that will be extended by useAnalytics hook
|
|
84
|
-
*/
|
|
85
|
-
export function useDataIngestionApi() {
|
|
86
|
-
const commonFields = getCommonAnalyticsContext();
|
|
87
|
-
|
|
88
|
-
const sendEvent = (fields: DataIngestionApiFields) => {
|
|
89
|
-
// Get workspace from environment
|
|
90
|
-
const workspace = isDevelopment() ? "dev" : "master";
|
|
91
|
-
|
|
92
|
-
dataIngestionApi({
|
|
93
|
-
...commonFields,
|
|
94
|
-
event_category: fields.event_category || "click",
|
|
95
|
-
...fields,
|
|
96
|
-
workspace,
|
|
97
|
-
});
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
return { sendEvent };
|
|
101
|
-
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ActorType } from "./types";
|
|
2
|
+
|
|
3
|
+
const INTERNAL_EMAIL_DOMAIN = "vtex.com";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Derive analytics actor_type from the logged-in user's email domain.
|
|
7
|
+
* Never logs or returns the input email — only the enum.
|
|
8
|
+
*/
|
|
9
|
+
export function deriveActorType(email?: string | null): ActorType {
|
|
10
|
+
if (!email) {
|
|
11
|
+
return "external";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const trimmed = email.trim().toLowerCase();
|
|
15
|
+
const atIndex = trimmed.lastIndexOf("@");
|
|
16
|
+
|
|
17
|
+
if (atIndex < 0 || atIndex === trimmed.length - 1) {
|
|
18
|
+
return "external";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const domain = trimmed.slice(atIndex + 1);
|
|
22
|
+
|
|
23
|
+
return domain === INTERNAL_EMAIL_DOMAIN ? "internal" : "external";
|
|
24
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { deriveActorType } from "./deriveActorType";
|
|
2
|
+
|
|
3
|
+
import type { ActorType, DataIngestionApiFields } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Attach derived actor_type to analytics fields' metadata.
|
|
7
|
+
* Caller-supplied actor_type is ignored — derivation always wins.
|
|
8
|
+
* Never copies email or other identity into the payload.
|
|
9
|
+
*/
|
|
10
|
+
export function mergeActorTypeIntoEvent(
|
|
11
|
+
fields: DataIngestionApiFields,
|
|
12
|
+
email?: string | null
|
|
13
|
+
): DataIngestionApiFields {
|
|
14
|
+
let actorType: ActorType;
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
actorType = deriveActorType(email);
|
|
18
|
+
} catch {
|
|
19
|
+
actorType = "external";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
...fields,
|
|
24
|
+
metadata: {
|
|
25
|
+
...fields.metadata,
|
|
26
|
+
actor_type: actorType,
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
|
|
3
|
+
import { BuyerPortalContext } from "../../../components/BuyerPortalProvider/BuyerPortalProvider";
|
|
4
|
+
import { isDevelopment } from "../../../utils/environment";
|
|
5
|
+
|
|
6
|
+
import { dataIngestionApi, getCommonAnalyticsContext } from "./analytics";
|
|
7
|
+
import { mergeActorTypeIntoEvent } from "./mergeActorTypeIntoEvent";
|
|
8
|
+
|
|
9
|
+
import type { DataIngestionApiFields } from "./types";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Hook to send analytics events with actor_type derived from the logged-in user.
|
|
13
|
+
*/
|
|
14
|
+
export function useDataIngestionApi() {
|
|
15
|
+
const buyerPortalContext = useContext(BuyerPortalContext);
|
|
16
|
+
const commonFields = getCommonAnalyticsContext();
|
|
17
|
+
const email = buyerPortalContext?.currentUser?.email;
|
|
18
|
+
|
|
19
|
+
const sendEvent = (fields: DataIngestionApiFields) => {
|
|
20
|
+
const workspace = isDevelopment() ? "dev" : "master";
|
|
21
|
+
const enrichedFields = mergeActorTypeIntoEvent(fields, email);
|
|
22
|
+
|
|
23
|
+
dataIngestionApi({
|
|
24
|
+
...commonFields,
|
|
25
|
+
event_category: enrichedFields.event_category || "click",
|
|
26
|
+
...enrichedFields,
|
|
27
|
+
workspace,
|
|
28
|
+
});
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
return { sendEvent };
|
|
32
|
+
}
|