@vtex/faststore-plugin-buyer-portal 2.0.25 → 2.0.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "2.0.25",
3
+ "version": "2.0.26",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "files": [
@@ -104,7 +104,8 @@ export const CreateOrgUnitDrawer = ({
104
104
 
105
105
  trackEntityCreateError(ANALYTICS_EVENTS.ORG_UNIT_CREATE_ERROR, err, {
106
106
  parent_org_unit_id: parentOrgUnit?.id,
107
- error_type: error.code || "unknown",
107
+ errorType:
108
+ error.code === "InvalidOrganizationUnitName" ? "conflict" : undefined,
108
109
  });
109
110
 
110
111
  if (error.code === "InvalidOrganizationUnitName") {
@@ -0,0 +1,109 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { ClientError } from "../../../clients/Client";
4
+ import { buildErrorEventMetadata } from "../buildErrorEventMetadata";
5
+
6
+ describe("buildErrorEventMetadata", () => {
7
+ it("never includes error_message or error_stack, regardless of error shape", () => {
8
+ const error = new Error("email: alice@vtex.com, id: 123");
9
+
10
+ const result = buildErrorEventMetadata(error, undefined, {});
11
+
12
+ expect(result).not.toHaveProperty("error_message");
13
+ expect(result).not.toHaveProperty("error_stack");
14
+ expect(JSON.stringify(result)).not.toContain("alice@vtex.com");
15
+ });
16
+
17
+ it("never includes error_message or error_stack for string errors", () => {
18
+ const result = buildErrorEventMetadata("raw string error", undefined, {});
19
+
20
+ expect(result).not.toHaveProperty("error_message");
21
+ expect(result).not.toHaveProperty("error_stack");
22
+ });
23
+
24
+ it("includes error_type and error_code derived from classifyError", () => {
25
+ const error = new ClientError("boom", { status: 403 });
26
+
27
+ const result = buildErrorEventMetadata(
28
+ error,
29
+ { operation: "edit" },
30
+ { entityType: "budget" }
31
+ );
32
+
33
+ expect(result.error_type).toBe("permission_denied");
34
+ expect(result.error_code).toBe("budget.edit.permission_denied");
35
+ });
36
+
37
+ it("prefers an explicit errorType hint over status inference", () => {
38
+ const error = new ClientError("nope", { status: 409 });
39
+
40
+ const result = buildErrorEventMetadata(
41
+ error,
42
+ {
43
+ errorType: "user_already_exists",
44
+ entityType: "user",
45
+ operation: "create",
46
+ },
47
+ {}
48
+ );
49
+
50
+ expect(result.error_type).toBe("user_already_exists");
51
+ expect(result.error_code).toBe("user.create.user_already_exists");
52
+ });
53
+
54
+ it("does not leak the errorType hint itself as a stray field", () => {
55
+ const result = buildErrorEventMetadata(
56
+ "boom",
57
+ { errorType: "validation" },
58
+ {}
59
+ );
60
+
61
+ expect(result).not.toHaveProperty("errorType");
62
+ });
63
+
64
+ it("a caller-supplied ad-hoc error_type property in context can never override the classified value", () => {
65
+ const error = new ClientError("boom", { status: 403 });
66
+
67
+ const result = buildErrorEventMetadata(
68
+ error,
69
+ { error_type: "totally_made_up" } as never,
70
+ {}
71
+ );
72
+
73
+ expect(result.error_type).toBe("permission_denied");
74
+ });
75
+
76
+ it("sets is_new_entity to false by default and true when isNew is passed", () => {
77
+ expect(buildErrorEventMetadata("boom", undefined, {}).is_new_entity).toBe(
78
+ false
79
+ );
80
+ expect(
81
+ buildErrorEventMetadata("boom", { isNew: true }, {}).is_new_entity
82
+ ).toBe(true);
83
+ });
84
+
85
+ it("applies entity_type/entity_id defaults from the hook config, and passes through operation", () => {
86
+ const result = buildErrorEventMetadata(
87
+ "boom",
88
+ { operation: "create" },
89
+ {
90
+ entityType: "org_unit",
91
+ entityId: "abc",
92
+ }
93
+ );
94
+
95
+ expect(result.entity_type).toBe("org_unit");
96
+ expect(result.entity_id).toBe("abc");
97
+ expect(result.operation).toBe("create");
98
+ });
99
+
100
+ it("preserves arbitrary pass-through properties from context", () => {
101
+ const result = buildErrorEventMetadata(
102
+ "boom",
103
+ { parent_org_unit_id: "org-1" } as never,
104
+ {}
105
+ );
106
+
107
+ expect(result.parent_org_unit_id).toBe("org-1");
108
+ });
109
+ });
@@ -0,0 +1,148 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { ClientError } from "../../../clients/Client";
4
+ import { classifyError } from "../classifyError";
5
+
6
+ describe("classifyError", () => {
7
+ describe("explicit hint precedence", () => {
8
+ it("uses a valid explicit hint over everything else", () => {
9
+ const error = new ClientError("conflict", { status: 500 });
10
+
11
+ const result = classifyError(error, "user_already_exists");
12
+
13
+ expect(result.error_type).toBe("user_already_exists");
14
+ });
15
+
16
+ it("ignores an invalid/unknown hint and falls back to status inference", () => {
17
+ const error = new ClientError("nope", { status: 403 });
18
+
19
+ const result = classifyError(error, "not_a_real_error_type" as never);
20
+
21
+ expect(result.error_type).toBe("permission_denied");
22
+ });
23
+ });
24
+
25
+ describe("HTTP status mapping", () => {
26
+ it.each([
27
+ [409, "conflict"],
28
+ [400, "validation"],
29
+ [422, "validation"],
30
+ [403, "permission_denied"],
31
+ [404, "not_found"],
32
+ [429, "rate_limited"],
33
+ [500, "server_error"],
34
+ [503, "server_error"],
35
+ [599, "server_error"],
36
+ ] as const)("maps status %d to %s", (status, expected) => {
37
+ const error = new ClientError("boom", { status });
38
+
39
+ const result = classifyError(error);
40
+
41
+ expect(result.error_type).toBe(expected);
42
+ });
43
+
44
+ it("does not map 401 to permission_denied (falls through to unknown)", () => {
45
+ const error = new ClientError("unauthorized", { status: 401 });
46
+
47
+ const result = classifyError(error);
48
+
49
+ expect(result.error_type).toBe("unknown");
50
+ });
51
+
52
+ it("falls back to unknown for an unmapped status", () => {
53
+ const error = new ClientError("teapot", { status: 418 });
54
+
55
+ const result = classifyError(error);
56
+
57
+ expect(result.error_type).toBe("unknown");
58
+ });
59
+ });
60
+
61
+ describe("network fallback", () => {
62
+ it("classifies a fetch-level TypeError with no status as network", () => {
63
+ const error = new TypeError("Failed to fetch");
64
+
65
+ const result = classifyError(error);
66
+
67
+ expect(result.error_type).toBe("network");
68
+ });
69
+
70
+ it("classifies AbortError as network", () => {
71
+ const error = new Error("aborted");
72
+ error.name = "AbortError";
73
+
74
+ const result = classifyError(error);
75
+
76
+ expect(result.error_type).toBe("network");
77
+ });
78
+
79
+ it("classifies TimeoutError as network", () => {
80
+ const error = new Error("timed out");
81
+ error.name = "TimeoutError";
82
+
83
+ const result = classifyError(error);
84
+
85
+ expect(result.error_type).toBe("network");
86
+ });
87
+ });
88
+
89
+ describe("unknown fallback", () => {
90
+ it("classifies a plain Error with no status/network signature as unknown", () => {
91
+ const error = new Error("something broke");
92
+
93
+ const result = classifyError(error);
94
+
95
+ expect(result.error_type).toBe("unknown");
96
+ });
97
+
98
+ it("classifies a string error as unknown when no hint is given", () => {
99
+ const result = classifyError("Something failed");
100
+
101
+ expect(result.error_type).toBe("unknown");
102
+ });
103
+
104
+ it("respects an explicit hint even when the error is a string", () => {
105
+ const result = classifyError("Something failed", "validation");
106
+
107
+ expect(result.error_type).toBe("validation");
108
+ });
109
+ });
110
+
111
+ describe("error_code format", () => {
112
+ it("builds a 3-segment error_code from entity/operation context and the resolved error_type", () => {
113
+ const error = new ClientError("boom", { status: 403 });
114
+
115
+ const result = classifyError(error, undefined, {
116
+ entityType: "budget",
117
+ operation: "edit",
118
+ });
119
+
120
+ expect(result.error_code).toBe("budget.edit.permission_denied");
121
+ });
122
+
123
+ it("replaces missing entity_type/operation with the literal 'unknown'", () => {
124
+ const error = new TypeError("Failed to fetch");
125
+
126
+ const result = classifyError(error);
127
+
128
+ expect(result.error_code).toBe("unknown.unknown.network");
129
+ });
130
+
131
+ it("uses the explicit hint as the reason segment", () => {
132
+ const result = classifyError("boom", "user_already_exists", {
133
+ entityType: "user",
134
+ operation: "create",
135
+ });
136
+
137
+ expect(result.error_code).toBe("user.create.user_already_exists");
138
+ });
139
+ });
140
+
141
+ describe("never throws", () => {
142
+ it("does not throw for weird/malformed input", () => {
143
+ expect(() =>
144
+ classifyError({} as unknown as Error, undefined, {})
145
+ ).not.toThrow();
146
+ });
147
+ });
148
+ });
@@ -0,0 +1,60 @@
1
+ import { classifyError } from "./classifyError";
2
+
3
+ import type { ErrorType } from "./types";
4
+
5
+ export interface ErrorEventContext {
6
+ entityType?: string;
7
+ entityId?: string;
8
+ isNew?: boolean;
9
+ operation?: string;
10
+ /** Explicit classification hint, forwarded to `classifyError` as its `hint`. */
11
+ errorType?: ErrorType;
12
+ [key: string]: unknown;
13
+ }
14
+
15
+ export interface ErrorEventDefaults {
16
+ entityType?: string;
17
+ entityId?: string;
18
+ }
19
+
20
+ /**
21
+ * Build the `metadata` object for an error analytics event.
22
+ *
23
+ * Classifies the error into `error_type`/`error_code` via `classifyError`
24
+ * and merges in entity/operation context. `error_message` and `error_stack`
25
+ * are never read from the input error, so they can never end up on the
26
+ * returned object.
27
+ *
28
+ * `error_type` / `error_code` are always applied last, so a stray
29
+ * caller-supplied property with the same key (e.g. a legacy ad-hoc
30
+ * `properties.error_type` override) can never take precedence over the
31
+ * classified value.
32
+ */
33
+ export function buildErrorEventMetadata(
34
+ error: Error | string,
35
+ context: ErrorEventContext | undefined,
36
+ defaults: ErrorEventDefaults = {}
37
+ ): Record<string, unknown> {
38
+ const { errorType, entityType, entityId, isNew, operation, ...rest } =
39
+ context ?? {};
40
+
41
+ const resolvedEntityType = entityType ?? defaults.entityType;
42
+ const resolvedEntityId = entityId ?? defaults.entityId;
43
+
44
+ const { error_type, error_code } = classifyError(error, errorType, {
45
+ entityType: resolvedEntityType,
46
+ operation,
47
+ });
48
+
49
+ return {
50
+ ...rest,
51
+ ...(resolvedEntityId !== undefined && { entity_id: resolvedEntityId }),
52
+ ...(resolvedEntityType !== undefined && {
53
+ entity_type: resolvedEntityType,
54
+ }),
55
+ is_new_entity: isNew ?? false,
56
+ operation,
57
+ error_type,
58
+ error_code,
59
+ };
60
+ }
@@ -0,0 +1,120 @@
1
+ import { ERROR_TYPE_VALUES } from "./types";
2
+
3
+ import type { ErrorType } from "./types";
4
+
5
+ /**
6
+ * HTTP status -> ErrorType mapping used when no explicit classification hint
7
+ * is provided. 401 is intentionally omitted: it is already handled by
8
+ * `handleUnauthorizedRedirect` (session redirect) as a distinct flow, so if a
9
+ * 401 reaches `classifyError` it falls through to "unknown" rather than being
10
+ * conflated with 403.
11
+ */
12
+ const STATUS_TO_ERROR_TYPE: Record<number, ErrorType> = {
13
+ 400: "validation",
14
+ 422: "validation",
15
+ 403: "permission_denied",
16
+ 404: "not_found",
17
+ 409: "conflict",
18
+ 429: "rate_limited",
19
+ };
20
+
21
+ const UNKNOWN_SEGMENT = "unknown";
22
+
23
+ function isErrorType(value: unknown): value is ErrorType {
24
+ return (
25
+ typeof value === "string" &&
26
+ (ERROR_TYPE_VALUES as readonly string[]).includes(value)
27
+ );
28
+ }
29
+
30
+ function getStatus(error: Error | string): number | undefined {
31
+ if (typeof error === "string") {
32
+ return undefined;
33
+ }
34
+
35
+ const status = (error as { status?: unknown }).status;
36
+ return typeof status === "number" ? status : undefined;
37
+ }
38
+
39
+ function statusToErrorType(status: number | undefined): ErrorType | undefined {
40
+ if (status === undefined) {
41
+ return undefined;
42
+ }
43
+
44
+ if (status >= 500 && status <= 599) {
45
+ return "server_error";
46
+ }
47
+
48
+ return STATUS_TO_ERROR_TYPE[status];
49
+ }
50
+
51
+ function looksLikeNetworkFailure(error: Error | string): boolean {
52
+ if (typeof error === "string") {
53
+ return false;
54
+ }
55
+
56
+ if (error.name === "AbortError" || error.name === "TimeoutError") {
57
+ return true;
58
+ }
59
+
60
+ return (
61
+ error.name === "TypeError" &&
62
+ typeof error.message === "string" &&
63
+ error.message.toLowerCase().includes("fetch")
64
+ );
65
+ }
66
+
67
+ function resolveErrorType(error: Error | string, hint?: ErrorType): ErrorType {
68
+ if (isErrorType(hint)) {
69
+ return hint;
70
+ }
71
+
72
+ const mappedFromStatus = statusToErrorType(getStatus(error));
73
+ if (mappedFromStatus) {
74
+ return mappedFromStatus;
75
+ }
76
+
77
+ if (looksLikeNetworkFailure(error)) {
78
+ return "network";
79
+ }
80
+
81
+ return "unknown";
82
+ }
83
+
84
+ function toSegment(value?: string): string {
85
+ return value && value.trim() ? value.trim() : UNKNOWN_SEGMENT;
86
+ }
87
+
88
+ function buildErrorCode(
89
+ reason: ErrorType,
90
+ context?: { entityType?: string; operation?: string }
91
+ ): string {
92
+ return `${toSegment(context?.entityType)}.${toSegment(
93
+ context?.operation
94
+ )}.${reason}`;
95
+ }
96
+
97
+ /**
98
+ * Classify an error into a fixed, business-meaningful `error_type` and a
99
+ * finer-grained `error_code` for analytics. Pure, synchronous, never throws,
100
+ * and never reads `error.message` / `error.stack` into the return value.
101
+ *
102
+ * Classification precedence: explicit `hint` (if a valid `ErrorType`) > HTTP
103
+ * status on a `ClientError`-shaped error > network-failure signature >
104
+ * `"unknown"`.
105
+ */
106
+ export function classifyError(
107
+ error: Error | string,
108
+ hint?: ErrorType,
109
+ context?: { entityType?: string; operation?: string }
110
+ ): { error_type: ErrorType; error_code: string } {
111
+ try {
112
+ const error_type = resolveErrorType(error, hint);
113
+ return { error_type, error_code: buildErrorCode(error_type, context) };
114
+ } catch {
115
+ return {
116
+ error_type: "unknown",
117
+ error_code: buildErrorCode("unknown", context),
118
+ };
119
+ }
120
+ }
@@ -22,3 +22,35 @@ export type EntityCreateErrorCorrelationFields = {
22
22
  operation_name?: string;
23
23
  operation_phase?: string;
24
24
  };
25
+
26
+ /**
27
+ * Fixed, finite set of business-meaningful error categories emitted as `error_type`
28
+ * on every error analytics event.
29
+ *
30
+ * This is a frozen contract shared with B2BTEAM-3642 (Redshift column + QuickSight
31
+ * breakdown). Adding a member requires updating the spec
32
+ * (`specs/b2bteam-3641-error-type-emission.md`) and coordinating with that dataset —
33
+ * it is not something an individual call site can extend unilaterally.
34
+ */
35
+ export type ErrorType =
36
+ | "user_already_exists" // domain-specific conflict: entity/user creation collides with an existing one
37
+ | "validation" // request rejected due to invalid input (400-class, non-conflict)
38
+ | "conflict" // generic 409-class or domain conflict other than user_already_exists (e.g. duplicate name)
39
+ | "permission_denied" // 403-class — actor lacks permission for the operation
40
+ | "not_found" // 404-class — target entity does not exist
41
+ | "rate_limited" // 429-class
42
+ | "network" // fetch-level failure: no response reached the client (offline, DNS, CORS, abort, timeout)
43
+ | "server_error" // 5xx-class — upstream/server failure
44
+ | "unknown"; // anything that does not match the above
45
+
46
+ export const ERROR_TYPE_VALUES: readonly ErrorType[] = [
47
+ "user_already_exists",
48
+ "validation",
49
+ "conflict",
50
+ "permission_denied",
51
+ "not_found",
52
+ "rate_limited",
53
+ "network",
54
+ "server_error",
55
+ "unknown",
56
+ ];
@@ -2,9 +2,12 @@ import { useCallback, useEffect, useRef } from "react";
2
2
 
3
3
  import { useDataIngestionApi } from "../../services/logger/analytics/useDataIngestionApi";
4
4
 
5
+ import { buildErrorEventMetadata } from "./buildErrorEventMetadata";
6
+
5
7
  import type {
6
8
  AnalyticsTimer,
7
9
  EntityCreateErrorCorrelationFields,
10
+ ErrorType,
8
11
  UseAnalyticsConfig,
9
12
  } from "./types";
10
13
 
@@ -65,19 +68,13 @@ export function useAnalytics(options: UseAnalyticsConfig) {
65
68
  entityId?: string;
66
69
  isNew?: boolean;
67
70
  operation?: string;
71
+ errorType?: ErrorType;
68
72
  }
69
73
  ) => {
70
- const errorMessage = typeof error === "string" ? error : error.message;
71
- const errorStack = typeof error === "string" ? undefined : error.stack;
72
-
73
- const errorData: Record<string, unknown> = {
74
- error_message: errorMessage,
75
- error_type: typeof error === "string" ? "string" : error.name,
76
- error_stack: errorStack,
77
- ...withDefaults(context),
78
- is_new_entity: context?.isNew ?? false,
79
- operation: context?.operation,
80
- };
74
+ const errorData = buildErrorEventMetadata(error, context, {
75
+ entityType,
76
+ entityId,
77
+ });
81
78
 
82
79
  sendEvent({
83
80
  event_name: eventName,
@@ -86,7 +83,7 @@ export function useAnalytics(options: UseAnalyticsConfig) {
86
83
  metadata: errorData,
87
84
  });
88
85
  },
89
- [sendEvent]
86
+ [sendEvent, entityType, entityId]
90
87
  );
91
88
 
92
89
  /**
@@ -186,7 +183,8 @@ export function useAnalytics(options: UseAnalyticsConfig) {
186
183
  (
187
184
  eventName: string,
188
185
  error: Error | string,
189
- properties?: Record<string, unknown> & EntityCreateErrorCorrelationFields
186
+ properties?: Record<string, unknown> &
187
+ EntityCreateErrorCorrelationFields & { errorType?: ErrorType }
190
188
  ) => {
191
189
  const duration = endTimer(defaultTimerName);
192
190
  if (duration) {
@@ -221,7 +219,7 @@ export function useAnalytics(options: UseAnalyticsConfig) {
221
219
  entityType: string,
222
220
  entityId: string,
223
221
  error: Error | string,
224
- properties?: Record<string, unknown>
222
+ properties?: Record<string, unknown> & { errorType?: ErrorType }
225
223
  ) => {
226
224
  trackError(eventName, error, {
227
225
  entityType,
@@ -1,3 +1,5 @@
1
+ import type { ErrorType } from "../../../hooks/analytics/types";
2
+
1
3
  /**
2
4
  * Actor classification for analytics events (internal VTEX vs external).
3
5
  */
@@ -70,12 +72,17 @@ export interface StepTimingProperties {
70
72
  }
71
73
 
72
74
  /**
73
- * Error tracking properties
75
+ * Error tracking properties.
76
+ *
77
+ * `error_message` / `error_stack` are intentionally not modeled here — they
78
+ * must never be emitted to the Data Ingestion API (see
79
+ * `specs/b2bteam-3641-error-type-emission.md`). `error_type` is the fixed,
80
+ * finite `ErrorType` enum; `error_code` is the `"{entity}.{op}.{reason}"`
81
+ * string built by `classifyError`.
74
82
  */
75
83
  export interface ErrorEventProperties {
76
- error_message: string;
77
- error_type?: string;
78
- error_stack?: string;
84
+ error_type: ErrorType;
85
+ error_code: string;
79
86
  entity_type?: string;
80
87
  entity_id?: string;
81
88
  is_new_entity: boolean;
@@ -22,4 +22,4 @@ export const SCOPE_KEYS = {
22
22
  CREDIT_CARDS: "creditCards",
23
23
  } as const;
24
24
 
25
- export const CURRENT_VERSION = "2.0.25";
25
+ export const CURRENT_VERSION = "2.0.26";
@@ -143,10 +143,10 @@ export const CreateUserDrawer = ({
143
143
 
144
144
  trackEntityCreateError(ANALYTICS_EVENTS.USER_CREATE_ERROR, err, {
145
145
  org_unit_id: orgUnitId,
146
- error_type:
146
+ errorType:
147
147
  error.code === "USER_ALREADY_EXISTS"
148
148
  ? "user_already_exists"
149
- : "unknown",
149
+ : undefined,
150
150
  });
151
151
 
152
152
  setUserAlreadyInUse({
@@ -227,7 +227,10 @@ export const CreateUserDrawerWithUsername = ({
227
227
 
228
228
  trackEntityCreateError(ANALYTICS_EVENTS.USER_CREATE_ERROR, err, {
229
229
  org_unit_id: orgUnitId,
230
- error_type: error.code || "unknown",
230
+ errorType:
231
+ error.code === "EMAIL_ALREADY_EXISTS"
232
+ ? "user_already_exists"
233
+ : undefined,
231
234
  });
232
235
 
233
236
  if (error.code === "EMAIL_ALREADY_EXISTS") {