@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 +1 -1
- package/src/features/org-units/components/CreateOrgUnitDrawer/CreateOrgUnitDrawer.tsx +2 -1
- package/src/features/shared/hooks/analytics/__tests__/buildErrorEventMetadata.test.ts +109 -0
- package/src/features/shared/hooks/analytics/__tests__/classifyError.test.ts +148 -0
- package/src/features/shared/hooks/analytics/buildErrorEventMetadata.ts +60 -0
- package/src/features/shared/hooks/analytics/classifyError.ts +120 -0
- package/src/features/shared/hooks/analytics/types.ts +32 -0
- package/src/features/shared/hooks/analytics/useAnalytics.ts +12 -14
- package/src/features/shared/services/logger/analytics/types.ts +11 -4
- package/src/features/shared/utils/constants.ts +1 -1
- package/src/features/users/components/CreateUserDrawer/CreateUserDrawer.tsx +2 -2
- package/src/features/users/components/CreateUserDrawerWithUsername/CreateUserDrawerWithUsername.tsx +4 -1
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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> &
|
|
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
|
-
|
|
77
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
146
|
+
errorType:
|
|
147
147
|
error.code === "USER_ALREADY_EXISTS"
|
|
148
148
|
? "user_already_exists"
|
|
149
|
-
:
|
|
149
|
+
: undefined,
|
|
150
150
|
});
|
|
151
151
|
|
|
152
152
|
setUserAlreadyInUse({
|
package/src/features/users/components/CreateUserDrawerWithUsername/CreateUserDrawerWithUsername.tsx
CHANGED
|
@@ -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
|
-
|
|
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") {
|