@vtex/faststore-plugin-buyer-portal 1.3.12 → 1.3.14
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 +19 -2
- package/package.json +1 -1
- package/src/features/budgets/components/BudgetAllocationsSelection/BudgetAllocationsSelection.tsx +35 -18
- package/src/features/budgets/components/BudgetAllocationsSelection/budget-allocations-selection.scss +1 -0
- package/src/features/budgets/hooks/index.ts +1 -0
- package/src/features/budgets/hooks/useClickOutside.ts +26 -0
- package/src/features/custom-fields/layouts/CustomFieldsLayout/CustomFieldsLayout.tsx +1 -1
- package/src/features/shared/components/ErrorBoundary/ErrorBoundary.tsx +21 -2
- package/src/features/shared/hooks/index.ts +1 -0
- package/src/features/shared/hooks/useLogger.ts +72 -0
- package/src/features/shared/hooks/usePageItems.ts +2 -2
- package/src/features/shared/services/index.ts +1 -0
- package/src/features/shared/services/logger/constants.ts +49 -0
- package/src/features/shared/services/logger/context.ts +46 -0
- package/src/features/shared/services/logger/index.ts +4 -0
- package/src/features/shared/services/logger/otlp-logger.service.ts +241 -0
- package/src/features/shared/services/logger/types.ts +114 -0
- package/src/features/shared/utils/constants.ts +1 -1
- package/src/features/shared/utils/withClientErrorBoundary.ts +34 -13
- package/src/features/shared/utils/withLoaderErrorBoundary.ts +31 -0
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [1.3.14] - 2025-11-03
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Click outside on Dropdown are now properly closing the dropdown on Budget allocation selection
|
|
15
|
+
|
|
16
|
+
## [1.3.13] - 2025-11-03
|
|
17
|
+
|
|
18
|
+
- Setup OTLP to send log events on error boundary
|
|
19
|
+
|
|
10
20
|
## [1.3.12] - 2025-10-30
|
|
11
21
|
|
|
12
22
|
### Added
|
|
@@ -171,7 +181,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
171
181
|
- Add CHANGELOG file
|
|
172
182
|
- Add README file
|
|
173
183
|
|
|
174
|
-
[unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.
|
|
184
|
+
[unreleased]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.14...HEAD
|
|
175
185
|
[1.2.3]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.2.2...1.2.3
|
|
176
186
|
[1.2.3]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/1.2.3
|
|
177
187
|
[1.2.4]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/1.2.4
|
|
@@ -183,5 +193,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
183
193
|
[1.3.9]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.8...v1.3.9
|
|
184
194
|
[1.3.8]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.7...v1.3.8
|
|
185
195
|
[1.3.7]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/1.3.7
|
|
196
|
+
|
|
197
|
+
# <<<<<<< HEAD
|
|
198
|
+
|
|
199
|
+
[1.3.14]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.13...v1.3.14
|
|
200
|
+
[1.3.13]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.12...v1.3.13
|
|
186
201
|
[1.3.12]: https://github.com/vtex/faststore-plugin-buyer-portal/compare/v1.3.11...v1.3.12
|
|
187
|
-
|
|
202
|
+
|
|
203
|
+
> > > > > > > main
|
|
204
|
+
> > > > > > > [1.3.11]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/1.3.11
|
package/package.json
CHANGED
package/src/features/budgets/components/BudgetAllocationsSelection/BudgetAllocationsSelection.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useEffect, useRef } from "react";
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
Checkbox,
|
|
@@ -16,6 +16,7 @@ import { getTableColumns } from "../../../shared/components/Table/utils/tableCol
|
|
|
16
16
|
import { useBuyerPortal } from "../../../shared/hooks";
|
|
17
17
|
import { IPagination } from "../../../shared/types";
|
|
18
18
|
import { Dictionary } from "../../../shared/utils";
|
|
19
|
+
import { useClickOutside } from "../../hooks";
|
|
19
20
|
|
|
20
21
|
import type { BudgetAllocationsSelectionDataType } from "../../types";
|
|
21
22
|
|
|
@@ -48,12 +49,19 @@ export const BudgetAllocationsSelection = ({
|
|
|
48
49
|
onSortChange,
|
|
49
50
|
setFilter,
|
|
50
51
|
}: BudgetAllocationsSelectionProps) => {
|
|
52
|
+
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
|
53
|
+
|
|
51
54
|
const { currentContract: contract } = useBuyerPortal();
|
|
55
|
+
|
|
52
56
|
const options = Object.keys(sortingOptions);
|
|
53
57
|
|
|
54
58
|
const svgProps = { width: 10, height: 10 };
|
|
55
59
|
const selectAllRef = useRef<HTMLInputElement>(null);
|
|
56
60
|
|
|
61
|
+
const dropdownRef = useClickOutside<HTMLDivElement>(() => {
|
|
62
|
+
setIsDropdownOpen(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
57
65
|
const updateCurrentSort = (newSort: string) => {
|
|
58
66
|
onSortChange(newSort);
|
|
59
67
|
};
|
|
@@ -155,8 +163,11 @@ export const BudgetAllocationsSelection = ({
|
|
|
155
163
|
return (
|
|
156
164
|
<div data-fs-bp-budget-allocation-table>
|
|
157
165
|
<div data-fs-bp-budget-allocation-wrapper>
|
|
158
|
-
<Dropdown>
|
|
159
|
-
<DropdownButton
|
|
166
|
+
<Dropdown isOpen={isDropdownOpen}>
|
|
167
|
+
<DropdownButton
|
|
168
|
+
data-fs-bp-budget-allocation-select-current
|
|
169
|
+
onClick={() => setIsDropdownOpen((prev) => !prev)}
|
|
170
|
+
>
|
|
160
171
|
{currentSort}
|
|
161
172
|
<Icon
|
|
162
173
|
name="CaretRight"
|
|
@@ -166,21 +177,27 @@ export const BudgetAllocationsSelection = ({
|
|
|
166
177
|
/>
|
|
167
178
|
</DropdownButton>
|
|
168
179
|
<DropdownMenu align="left">
|
|
169
|
-
{
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
180
|
+
<div ref={dropdownRef}>
|
|
181
|
+
{options.map((option) => (
|
|
182
|
+
<DropdownItem
|
|
183
|
+
key={option}
|
|
184
|
+
onClick={(e) => {
|
|
185
|
+
e.stopPropagation();
|
|
186
|
+
updateCurrentSort(option);
|
|
187
|
+
setIsDropdownOpen(false);
|
|
188
|
+
}}
|
|
189
|
+
data-fs-bp-budget-allocation-select-item
|
|
190
|
+
data-fs-bp-budget-allocation-selected={currentSort === option}
|
|
191
|
+
>
|
|
192
|
+
<div data-fs-bp-budget-allocation-selected-wrapper>
|
|
193
|
+
<span>{option}</span>
|
|
194
|
+
{currentSort === option && (
|
|
195
|
+
<Icon name="Check" width={16} height={16} />
|
|
196
|
+
)}
|
|
197
|
+
</div>
|
|
198
|
+
</DropdownItem>
|
|
199
|
+
))}
|
|
200
|
+
</div>
|
|
184
201
|
</DropdownMenu>
|
|
185
202
|
</Dropdown>
|
|
186
203
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { RefObject, useEffect, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export function useClickOutside<T extends HTMLElement>(
|
|
4
|
+
callback: () => void
|
|
5
|
+
): RefObject<T> {
|
|
6
|
+
const ref = useRef<T>(null);
|
|
7
|
+
const callbackRef = useRef(callback);
|
|
8
|
+
|
|
9
|
+
// Update callbackRef to always hold the latest callback
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
callbackRef.current = callback;
|
|
12
|
+
}, [callback]);
|
|
13
|
+
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const handleClick = (event: Event) => {
|
|
16
|
+
if (ref.current && !ref.current.contains(event.target as Node)) {
|
|
17
|
+
callbackRef.current();
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
document.addEventListener("click", handleClick, true);
|
|
22
|
+
return () => document.removeEventListener("click", handleClick, true);
|
|
23
|
+
}, []);
|
|
24
|
+
|
|
25
|
+
return ref;
|
|
26
|
+
}
|
|
@@ -62,7 +62,7 @@ export const CustomFieldsLayout = ({
|
|
|
62
62
|
const lastSearchValue = useRef(filter.search);
|
|
63
63
|
const contractId = contract?.id ?? "";
|
|
64
64
|
const unitId = orgUnit?.id ?? "";
|
|
65
|
-
const cookie = clientContext
|
|
65
|
+
const cookie = clientContext?.cookie ?? "";
|
|
66
66
|
const debouncedSearchValue = useDebounce(filter.search, 500);
|
|
67
67
|
|
|
68
68
|
useEffect(() => {
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { Component, type ReactNode } from "react";
|
|
4
4
|
|
|
5
5
|
import { ErrorTabsLayout } from "../../layouts/ErrorTabsLayout/ErrorTabsLayout";
|
|
6
|
+
import { logError } from "../../services/logger";
|
|
6
7
|
import { isPluginError } from "../../utils/isPluginError";
|
|
7
8
|
|
|
8
9
|
interface Props {
|
|
@@ -35,8 +36,26 @@ export class ErrorBoundary extends Component<Props, State> {
|
|
|
35
36
|
}
|
|
36
37
|
|
|
37
38
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
38
|
-
if (isPluginError(error)
|
|
39
|
-
|
|
39
|
+
if (isPluginError(error)) {
|
|
40
|
+
// Automatically send error logs to OTLP endpoint
|
|
41
|
+
const metadata = {
|
|
42
|
+
"error.message": error.message,
|
|
43
|
+
"error.name": error.name,
|
|
44
|
+
"error.stack": error.stack || "No stack trace available",
|
|
45
|
+
"error.component": this.props.tags?.component || "Unknown",
|
|
46
|
+
"error.type": this.props.tags?.errorType || "Unknown",
|
|
47
|
+
"error.info":
|
|
48
|
+
errorInfo.componentStack || "No component stack available",
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
Promise.resolve().then(() =>
|
|
52
|
+
logError(`ErrorBoundary caught error: ${error.message}`, metadata)
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// Call custom onError handler if provided
|
|
56
|
+
if (this.props.onError) {
|
|
57
|
+
this.props.onError(error, errorInfo);
|
|
58
|
+
}
|
|
40
59
|
}
|
|
41
60
|
}
|
|
42
61
|
|
|
@@ -12,4 +12,5 @@ export { useAddToScope } from "./useAddToScope";
|
|
|
12
12
|
export { useRemoveFromScope } from "./useRemoveFromScope";
|
|
13
13
|
export { usePageItems, type UsePageItemsProps } from "./usePageItems";
|
|
14
14
|
export { useRouterLoading } from "./useRouterLoading";
|
|
15
|
+
export { useLogger } from "./useLogger";
|
|
15
16
|
export { useGetDependenciesVersion } from "./useGetDependenciesVersion";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useContext } from "react";
|
|
2
|
+
|
|
3
|
+
import { BuyerPortalContext } from "../components/BuyerPortalProvider/BuyerPortalProvider";
|
|
4
|
+
import { logError, logInfo, logWarn } from "../services/logger";
|
|
5
|
+
|
|
6
|
+
import type { LogContext, LogMetadata } from "../services/logger";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Hook to access logger functionality with automatic context enrichment
|
|
10
|
+
* from BuyerPortalContext
|
|
11
|
+
*/
|
|
12
|
+
export function useLogger() {
|
|
13
|
+
const buyerPortalContext = useContext(BuyerPortalContext);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get additional context from BuyerPortalContext if available
|
|
17
|
+
*/
|
|
18
|
+
const getAdditionalContext = (): Partial<LogContext> => {
|
|
19
|
+
if (!buyerPortalContext) {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const context: Partial<LogContext> = {};
|
|
24
|
+
|
|
25
|
+
if (buyerPortalContext.currentOrgUnit?.id) {
|
|
26
|
+
context.orgUnitId = buyerPortalContext.currentOrgUnit.id;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (buyerPortalContext.currentUser?.id) {
|
|
30
|
+
context.userId = buyerPortalContext.currentUser.id;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (buyerPortalContext.clientContext?.customerId) {
|
|
34
|
+
context.customerId = buyerPortalContext.clientContext.customerId;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return context;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Log an error message with optional metadata
|
|
42
|
+
*/
|
|
43
|
+
const error = (message: string, metadata?: LogMetadata): Promise<void> => {
|
|
44
|
+
const context = getAdditionalContext();
|
|
45
|
+
|
|
46
|
+
return logError(message, metadata, context);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Log a warning message with optional metadata
|
|
51
|
+
*/
|
|
52
|
+
const warn = (message: string, metadata?: LogMetadata): Promise<void> => {
|
|
53
|
+
const context = getAdditionalContext();
|
|
54
|
+
|
|
55
|
+
return logWarn(message, metadata, context);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Log an info message with optional metadata
|
|
60
|
+
*/
|
|
61
|
+
const info = (message: string, metadata?: LogMetadata): Promise<void> => {
|
|
62
|
+
const context = getAdditionalContext();
|
|
63
|
+
|
|
64
|
+
return logInfo(message, metadata, context);
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
error,
|
|
69
|
+
warn,
|
|
70
|
+
info,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -26,13 +26,13 @@ export type UsePageItemsProps<T> = {
|
|
|
26
26
|
};
|
|
27
27
|
|
|
28
28
|
export const usePageItems = <T>({
|
|
29
|
-
initialItems,
|
|
29
|
+
initialItems = [],
|
|
30
30
|
search,
|
|
31
31
|
page = 1,
|
|
32
32
|
searchParam = SEARCH_PARAMS.DEFAULT,
|
|
33
33
|
pageParam = PAGE_PARAMS.DEFAULT,
|
|
34
34
|
}: UsePageItemsProps<T>) => {
|
|
35
|
-
const [items, setItems] = useState<T[]>(initialItems
|
|
35
|
+
const [items, setItems] = useState<T[]>(initialItems);
|
|
36
36
|
const [total, setTotal] = useState<number>(initialItems.length);
|
|
37
37
|
const [searchTerm, setSearchTerm] = useState(search ?? "");
|
|
38
38
|
const [isLoading, setIsLoading] = useState(false);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { CURRENT_VERSION } from "../../utils/constants";
|
|
2
|
+
|
|
3
|
+
import { LogLevel, SeverityNumber } from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* OTLP Endpoints for VTEX Observability
|
|
7
|
+
*/
|
|
8
|
+
export const OTLP_ENDPOINTS = {
|
|
9
|
+
PRODUCTION: "https://stable.vtexobservability.com/v1/logs",
|
|
10
|
+
DEVELOPMENT: "https://beta.vtexobservability.com/v1/logs",
|
|
11
|
+
} as const;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Service resource attributes
|
|
15
|
+
*/
|
|
16
|
+
export const SERVICE_NAME = "faststore-plugin-buyer-portal";
|
|
17
|
+
export const SERVICE_VERSION = CURRENT_VERSION;
|
|
18
|
+
export const SERVICE_INDEX = "b2b_organization_account";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Scope name for logs
|
|
22
|
+
*/
|
|
23
|
+
export const SCOPE_NAME = "buyer-portal-web";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map log levels to OTLP severity numbers
|
|
27
|
+
*/
|
|
28
|
+
export const SEVERITY_MAP: Record<LogLevel, SeverityNumber> = {
|
|
29
|
+
INFO: SeverityNumber.INFO,
|
|
30
|
+
WARN: SeverityNumber.WARN,
|
|
31
|
+
ERROR: SeverityNumber.ERROR,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Attribute keys for structured logging
|
|
36
|
+
*/
|
|
37
|
+
export const LOG_ATTRIBUTES = {
|
|
38
|
+
ERROR_MESSAGE: "error.message",
|
|
39
|
+
ERROR_STACK: "error.stack",
|
|
40
|
+
ERROR_COMPONENT: "error.component",
|
|
41
|
+
ERROR_TYPE: "error.type",
|
|
42
|
+
ACCOUNT: "account",
|
|
43
|
+
ENVIRONMENT: "environment",
|
|
44
|
+
URL: "url",
|
|
45
|
+
USER_AGENT: "user_agent",
|
|
46
|
+
ORG_UNIT_ID: "org_unit_id",
|
|
47
|
+
USER_ID: "user_id",
|
|
48
|
+
CUSTOMER_ID: "customer_id",
|
|
49
|
+
} as const;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import storeConfig from "discovery.config";
|
|
2
|
+
|
|
3
|
+
import { isDevelopment } from "../../utils/environment";
|
|
4
|
+
|
|
5
|
+
import type { LogContext } from "./types";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Collect log context from the application environment
|
|
9
|
+
* Gathers account, environment, URL, user agent, and other metadata
|
|
10
|
+
*/
|
|
11
|
+
export function collectLogContext(
|
|
12
|
+
additionalContext?: Partial<LogContext>
|
|
13
|
+
): LogContext {
|
|
14
|
+
const context: LogContext = {
|
|
15
|
+
environment: isDevelopment() ? "development" : "production",
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// Collect browser context only if in browser environment
|
|
19
|
+
if (typeof window !== "undefined") {
|
|
20
|
+
context.url = window.location.href;
|
|
21
|
+
context.userAgent = navigator.userAgent;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Get VTEX account from store config
|
|
25
|
+
if (storeConfig?.api?.storeId) {
|
|
26
|
+
context.account = storeConfig.api.storeId;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Merge with additional context provided
|
|
30
|
+
if (additionalContext) {
|
|
31
|
+
Object.assign(context, additionalContext);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return context;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get current timestamp in Unix nanoseconds (OTLP format)
|
|
39
|
+
* JavaScript Date.now() returns milliseconds, so we multiply by 1,000,000
|
|
40
|
+
*/
|
|
41
|
+
export function getTimestampNano(): string {
|
|
42
|
+
const milliseconds = Date.now();
|
|
43
|
+
const nanoseconds = milliseconds * 1_000_000;
|
|
44
|
+
|
|
45
|
+
return nanoseconds.toString();
|
|
46
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import { isDevelopment } from "../../utils/environment";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
LOG_ATTRIBUTES,
|
|
5
|
+
OTLP_ENDPOINTS,
|
|
6
|
+
SCOPE_NAME,
|
|
7
|
+
SERVICE_INDEX,
|
|
8
|
+
SERVICE_NAME,
|
|
9
|
+
SERVICE_VERSION,
|
|
10
|
+
SEVERITY_MAP,
|
|
11
|
+
} from "./constants";
|
|
12
|
+
import { collectLogContext, getTimestampNano } from "./context";
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
LogContext,
|
|
16
|
+
LogMetadata,
|
|
17
|
+
LogParams,
|
|
18
|
+
OTLPKeyValue,
|
|
19
|
+
OTLPLogsPayload,
|
|
20
|
+
} from "./types";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert metadata object to OTLP key-value attributes
|
|
24
|
+
*/
|
|
25
|
+
function metadataToAttributes(metadata?: LogMetadata): OTLPKeyValue[] {
|
|
26
|
+
if (!metadata) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return Object.entries(metadata).map(([key, value]) => {
|
|
31
|
+
// Handle different value types
|
|
32
|
+
if (typeof value === "string") {
|
|
33
|
+
return { key, value: { stringValue: value } };
|
|
34
|
+
} else if (typeof value === "number") {
|
|
35
|
+
if (Number.isInteger(value)) {
|
|
36
|
+
return { key, value: { intValue: value } };
|
|
37
|
+
} else {
|
|
38
|
+
return { key, value: { doubleValue: value } };
|
|
39
|
+
}
|
|
40
|
+
} else if (typeof value === "boolean") {
|
|
41
|
+
return { key, value: { boolValue: value } };
|
|
42
|
+
} else {
|
|
43
|
+
// Convert complex objects to JSON string
|
|
44
|
+
return { key, value: { stringValue: JSON.stringify(value) } };
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Convert log context to OTLP attributes
|
|
51
|
+
*/
|
|
52
|
+
function contextToAttributes(context: LogContext): OTLPKeyValue[] {
|
|
53
|
+
const attributes: OTLPKeyValue[] = [];
|
|
54
|
+
|
|
55
|
+
if (context.account) {
|
|
56
|
+
attributes.push({
|
|
57
|
+
key: LOG_ATTRIBUTES.ACCOUNT,
|
|
58
|
+
value: { stringValue: context.account },
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (context.environment) {
|
|
63
|
+
attributes.push({
|
|
64
|
+
key: LOG_ATTRIBUTES.ENVIRONMENT,
|
|
65
|
+
value: { stringValue: context.environment },
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (context.url) {
|
|
70
|
+
attributes.push({
|
|
71
|
+
key: LOG_ATTRIBUTES.URL,
|
|
72
|
+
value: { stringValue: context.url },
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (context.userAgent) {
|
|
77
|
+
attributes.push({
|
|
78
|
+
key: LOG_ATTRIBUTES.USER_AGENT,
|
|
79
|
+
value: { stringValue: context.userAgent },
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (context.orgUnitId) {
|
|
84
|
+
attributes.push({
|
|
85
|
+
key: LOG_ATTRIBUTES.ORG_UNIT_ID,
|
|
86
|
+
value: { stringValue: context.orgUnitId },
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (context.userId) {
|
|
91
|
+
attributes.push({
|
|
92
|
+
key: LOG_ATTRIBUTES.USER_ID,
|
|
93
|
+
value: { stringValue: context.userId },
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (context.customerId) {
|
|
98
|
+
attributes.push({
|
|
99
|
+
key: LOG_ATTRIBUTES.CUSTOMER_ID,
|
|
100
|
+
value: { stringValue: context.customerId },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return attributes;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Build OTLP logs payload
|
|
109
|
+
*/
|
|
110
|
+
function buildOTLPPayload(params: LogParams): OTLPLogsPayload {
|
|
111
|
+
const { message, level, metadata, context } = params;
|
|
112
|
+
|
|
113
|
+
// Collect full context
|
|
114
|
+
const fullContext = collectLogContext(context);
|
|
115
|
+
|
|
116
|
+
// Build attributes from context and metadata
|
|
117
|
+
const contextAttributes = contextToAttributes(fullContext);
|
|
118
|
+
const metadataAttributes = metadataToAttributes(metadata);
|
|
119
|
+
const allAttributes = [...contextAttributes, ...metadataAttributes];
|
|
120
|
+
|
|
121
|
+
// Build OTLP payload structure
|
|
122
|
+
const payload: OTLPLogsPayload = {
|
|
123
|
+
resourceLogs: [
|
|
124
|
+
{
|
|
125
|
+
resource: {
|
|
126
|
+
attributes: [
|
|
127
|
+
{
|
|
128
|
+
key: "service.name",
|
|
129
|
+
value: { stringValue: SERVICE_NAME },
|
|
130
|
+
},
|
|
131
|
+
{
|
|
132
|
+
key: "service.version",
|
|
133
|
+
value: { stringValue: SERVICE_VERSION },
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
key: "vtex.search_index",
|
|
137
|
+
value: { stringValue: SERVICE_INDEX },
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
scopeLogs: [
|
|
142
|
+
{
|
|
143
|
+
scope: {
|
|
144
|
+
name: SCOPE_NAME,
|
|
145
|
+
version: SERVICE_VERSION,
|
|
146
|
+
},
|
|
147
|
+
logRecords: [
|
|
148
|
+
{
|
|
149
|
+
timeUnixNano: getTimestampNano(),
|
|
150
|
+
severityNumber: SEVERITY_MAP[level],
|
|
151
|
+
severityText: level,
|
|
152
|
+
body: {
|
|
153
|
+
stringValue: message,
|
|
154
|
+
},
|
|
155
|
+
attributes: allAttributes,
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
],
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
return payload;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get the appropriate OTLP endpoint based on environment
|
|
169
|
+
*/
|
|
170
|
+
function getEndpoint(): string {
|
|
171
|
+
return isDevelopment()
|
|
172
|
+
? OTLP_ENDPOINTS.DEVELOPMENT
|
|
173
|
+
: OTLP_ENDPOINTS.PRODUCTION;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Send log to OTLP endpoint
|
|
178
|
+
* This function handles errors silently to prevent logging from breaking the app
|
|
179
|
+
*/
|
|
180
|
+
export async function sendOTLPLog(params: LogParams): Promise<void> {
|
|
181
|
+
try {
|
|
182
|
+
const payload = buildOTLPPayload(params);
|
|
183
|
+
const endpoint = getEndpoint();
|
|
184
|
+
|
|
185
|
+
const response = await fetch(endpoint, {
|
|
186
|
+
method: "POST",
|
|
187
|
+
headers: {
|
|
188
|
+
"Content-Type": "application/json",
|
|
189
|
+
},
|
|
190
|
+
body: JSON.stringify(payload),
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
if (!response.ok) {
|
|
194
|
+
// Only log to console in development
|
|
195
|
+
if (isDevelopment()) {
|
|
196
|
+
console.warn(
|
|
197
|
+
`Failed to send log to OTLP endpoint: ${response.status} ${response.statusText}`
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (error) {
|
|
202
|
+
// Silently catch errors to prevent logging from breaking the app
|
|
203
|
+
// Only log to console in development
|
|
204
|
+
if (isDevelopment()) {
|
|
205
|
+
console.warn("Error sending log to OTLP endpoint:", error);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Convenience function to log an error
|
|
212
|
+
*/
|
|
213
|
+
export function logError(
|
|
214
|
+
message: string,
|
|
215
|
+
metadata?: LogMetadata,
|
|
216
|
+
context?: Partial<LogContext>
|
|
217
|
+
): Promise<void> {
|
|
218
|
+
return sendOTLPLog({ message, level: "ERROR", metadata, context });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Convenience function to log a warning
|
|
223
|
+
*/
|
|
224
|
+
export function logWarn(
|
|
225
|
+
message: string,
|
|
226
|
+
metadata?: LogMetadata,
|
|
227
|
+
context?: Partial<LogContext>
|
|
228
|
+
): Promise<void> {
|
|
229
|
+
return sendOTLPLog({ message, level: "WARN", metadata, context });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Convenience function to log info
|
|
234
|
+
*/
|
|
235
|
+
export function logInfo(
|
|
236
|
+
message: string,
|
|
237
|
+
metadata?: LogMetadata,
|
|
238
|
+
context?: Partial<LogContext>
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
return sendOTLPLog({ message, level: "INFO", metadata, context });
|
|
241
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTLP Log Types
|
|
3
|
+
* Based on OpenTelemetry Protocol Specification for Logs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type LogLevel = "ERROR" | "WARN" | "INFO";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* OTLP Severity Number mapping
|
|
10
|
+
* https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
|
|
11
|
+
*/
|
|
12
|
+
export enum SeverityNumber {
|
|
13
|
+
UNSPECIFIED = 0,
|
|
14
|
+
INFO = 9,
|
|
15
|
+
WARN = 13,
|
|
16
|
+
ERROR = 17,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* OTLP Attribute value types
|
|
21
|
+
*/
|
|
22
|
+
export type OTLPAttributeValue = {
|
|
23
|
+
stringValue?: string;
|
|
24
|
+
intValue?: number;
|
|
25
|
+
doubleValue?: number;
|
|
26
|
+
boolValue?: boolean;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* OTLP Key-Value pair for attributes
|
|
31
|
+
*/
|
|
32
|
+
export type OTLPKeyValue = {
|
|
33
|
+
key: string;
|
|
34
|
+
value: OTLPAttributeValue;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* OTLP Log Record structure
|
|
39
|
+
*/
|
|
40
|
+
export type OTLPLogRecord = {
|
|
41
|
+
timeUnixNano: string;
|
|
42
|
+
severityNumber: SeverityNumber;
|
|
43
|
+
severityText: LogLevel;
|
|
44
|
+
body: {
|
|
45
|
+
stringValue: string;
|
|
46
|
+
};
|
|
47
|
+
attributes: OTLPKeyValue[];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* OTLP Resource structure
|
|
52
|
+
*/
|
|
53
|
+
export type OTLPResource = {
|
|
54
|
+
attributes: OTLPKeyValue[];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* OTLP Scope structure
|
|
59
|
+
*/
|
|
60
|
+
export type OTLPScope = {
|
|
61
|
+
name: string;
|
|
62
|
+
version?: string;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* OTLP Scope Logs structure
|
|
67
|
+
*/
|
|
68
|
+
export type OTLPScopeLogs = {
|
|
69
|
+
scope: OTLPScope;
|
|
70
|
+
logRecords: OTLPLogRecord[];
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* OTLP Resource Logs structure
|
|
75
|
+
*/
|
|
76
|
+
export type OTLPResourceLogs = {
|
|
77
|
+
resource: OTLPResource;
|
|
78
|
+
scopeLogs: OTLPScopeLogs[];
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* OTLP Logs Payload (top-level structure)
|
|
83
|
+
*/
|
|
84
|
+
export type OTLPLogsPayload = {
|
|
85
|
+
resourceLogs: OTLPResourceLogs[];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Log metadata that can be passed to logger functions
|
|
90
|
+
*/
|
|
91
|
+
export type LogMetadata = Record<string, unknown>;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Log context collected from the application
|
|
95
|
+
*/
|
|
96
|
+
export type LogContext = {
|
|
97
|
+
account?: string;
|
|
98
|
+
environment: "production" | "development";
|
|
99
|
+
url?: string;
|
|
100
|
+
userAgent?: string;
|
|
101
|
+
orgUnitId?: string;
|
|
102
|
+
userId?: string;
|
|
103
|
+
customerId?: string;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Parameters for creating a log entry
|
|
108
|
+
*/
|
|
109
|
+
export type LogParams = {
|
|
110
|
+
message: string;
|
|
111
|
+
level: LogLevel;
|
|
112
|
+
metadata?: LogMetadata;
|
|
113
|
+
context?: Partial<LogContext>;
|
|
114
|
+
};
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { logError } from "../services/logger";
|
|
2
|
+
|
|
1
3
|
import { goHome } from "./getHome";
|
|
2
4
|
|
|
3
5
|
import type { ClientError } from "../clients/Client";
|
|
@@ -32,21 +34,40 @@ export function withClientErrorBoundary<TArgs extends unknown[], TReturn>(
|
|
|
32
34
|
try {
|
|
33
35
|
return await clientFunction(...args);
|
|
34
36
|
} catch (error) {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
37
|
+
const errorObj = error as Error;
|
|
38
|
+
const clientError = error as Partial<ClientError>;
|
|
39
|
+
|
|
40
|
+
// Extract request information
|
|
41
|
+
const requestInfo = {
|
|
42
|
+
url: typeof clientError.url === "string" ? clientError.url : "unknown",
|
|
43
|
+
method:
|
|
44
|
+
typeof clientError.method === "string"
|
|
45
|
+
? clientError.method
|
|
46
|
+
: "unknown",
|
|
47
|
+
data:
|
|
48
|
+
clientError.responseData !== undefined
|
|
49
|
+
? clientError.responseData
|
|
50
|
+
: undefined,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
Promise.resolve().then(() =>
|
|
54
|
+
logError(`Client error: ${errorObj.message}`, {
|
|
55
|
+
"error.message": errorObj.message,
|
|
56
|
+
"error.name": errorObj.name,
|
|
57
|
+
"error.stack": errorObj.stack || "No stack trace available",
|
|
58
|
+
"error.component": componentName || "Unknown",
|
|
59
|
+
"error.type": "client_error",
|
|
60
|
+
"client.url": requestInfo.url,
|
|
61
|
+
"client.method": requestInfo.method,
|
|
62
|
+
"client.status": clientError.status?.toString() || "unknown",
|
|
63
|
+
"client.response_data":
|
|
64
|
+
requestInfo.data !== undefined
|
|
65
|
+
? JSON.stringify(requestInfo.data)
|
|
47
66
|
: undefined,
|
|
48
|
-
}
|
|
67
|
+
})
|
|
68
|
+
);
|
|
49
69
|
|
|
70
|
+
if (onError && error instanceof Error) {
|
|
50
71
|
onError(error as ClientError, requestInfo);
|
|
51
72
|
}
|
|
52
73
|
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { logError } from "../services/logger";
|
|
2
|
+
|
|
1
3
|
import { getClientContext } from "./getClientContext";
|
|
2
4
|
import { serializeLoaderData } from "./serializeLoaderData";
|
|
3
5
|
|
|
@@ -25,6 +27,20 @@ export function withLoaderErrorBoundary<TQuery, TReturn>(
|
|
|
25
27
|
query: data.query,
|
|
26
28
|
});
|
|
27
29
|
|
|
30
|
+
// Log error to OTLP endpoint
|
|
31
|
+
const errorObj = error as Error;
|
|
32
|
+
|
|
33
|
+
Promise.resolve().then(() =>
|
|
34
|
+
logError(`Loader error: ${errorObj.message}`, {
|
|
35
|
+
"error.message": errorObj.message,
|
|
36
|
+
"error.name": errorObj.name,
|
|
37
|
+
"error.stack": errorObj.stack || "No stack trace available",
|
|
38
|
+
"error.component": componentName || "Unknown",
|
|
39
|
+
"error.type": "loader_error",
|
|
40
|
+
"loader.query": JSON.stringify(data.query),
|
|
41
|
+
})
|
|
42
|
+
);
|
|
43
|
+
|
|
28
44
|
if (onError) {
|
|
29
45
|
onError(error as Error, data.query);
|
|
30
46
|
}
|
|
@@ -38,6 +54,21 @@ export function withLoaderErrorBoundary<TQuery, TReturn>(
|
|
|
38
54
|
`[${componentName || "Loader"}] Failed to get client context:`,
|
|
39
55
|
contextError
|
|
40
56
|
);
|
|
57
|
+
|
|
58
|
+
// Log context retrieval error
|
|
59
|
+
const contextErrorObj = contextError as Error;
|
|
60
|
+
|
|
61
|
+
Promise.resolve().then(() =>
|
|
62
|
+
logError(`Failed to get client context in loader`, {
|
|
63
|
+
"error.message": contextErrorObj.message,
|
|
64
|
+
"error.name": contextErrorObj.name,
|
|
65
|
+
"error.stack":
|
|
66
|
+
contextErrorObj.stack || "No stack trace available",
|
|
67
|
+
"error.component": componentName || "Unknown",
|
|
68
|
+
"error.type": "loader_context_error",
|
|
69
|
+
})
|
|
70
|
+
);
|
|
71
|
+
|
|
41
72
|
clientContext = {
|
|
42
73
|
cookie: "",
|
|
43
74
|
customerId: "",
|