@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 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.12...HEAD
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
- [1.3.11]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/1.3.11
202
+
203
+ > > > > > > > main
204
+ > > > > > > > [1.3.11]: https://github.com/vtex/faststore-plugin-buyer-portal/releases/tag/1.3.11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vtex/faststore-plugin-buyer-portal",
3
- "version": "1.3.12",
3
+ "version": "1.3.14",
4
4
  "description": "A plugin for faststore with buyer portal",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -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 data-fs-bp-budget-allocation-select-current>
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
- {options.map((option) => (
170
- <DropdownItem
171
- key={option}
172
- onClick={() => updateCurrentSort(option)}
173
- data-fs-bp-budget-allocation-select-item
174
- data-fs-bp-budget-allocation-selected={currentSort === option}
175
- >
176
- <div data-fs-bp-budget-allocation-selected-wrapper>
177
- <span>{option}</span>
178
- {currentSort === option && (
179
- <Icon name="Check" width={16} height={16} />
180
- )}
181
- </div>
182
- </DropdownItem>
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
 
@@ -137,5 +137,6 @@
137
137
  align-items: center;
138
138
  justify-content: space-between;
139
139
  width: 100%;
140
+ gap: var(--fs-spacing-2);
140
141
  }
141
142
  }
@@ -8,3 +8,4 @@ export { useListBudgets } from "./useListBudgets";
8
8
  export { useUpdateBudget } from "./useUpdateBudget";
9
9
  export { useListUsers } from "./useListUsers";
10
10
  export { useListAddresses } from "./useListAddressess";
11
+ export { useClickOutside } from "./useClickOutside";
@@ -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.cookie;
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) && this.props.onError) {
39
- this.props.onError(error, errorInfo);
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);
@@ -10,6 +10,7 @@ export {
10
10
  validateAccessService,
11
11
  type ValidateAccessServiceProps,
12
12
  } from "./validate-access.service";
13
+ export * from "./logger";
13
14
  export {
14
15
  getDependenciesVersionService,
15
16
  type GetDependenciesVersionProps,
@@ -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,4 @@
1
+ export * from "./types";
2
+ export * from "./constants";
3
+ export * from "./context";
4
+ export * from "./otlp-logger.service";
@@ -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
+ };
@@ -13,4 +13,4 @@ export const LOCAL_STORAGE_LOCATION_EDIT_KEY = "bp_hide_edit_location_confirm";
13
13
  export const LOCAL_STORAGE_RECIPIENT_EDIT_KEY =
14
14
  "bp_hide_edit_recipient_confirm";
15
15
 
16
- export const CURRENT_VERSION = "1.3.12";
16
+ export const CURRENT_VERSION = "1.3.14";
@@ -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
- if (onError && error instanceof Error) {
36
- const clientError = error as Partial<ClientError>;
37
- const requestInfo = {
38
- url:
39
- typeof clientError.url === "string" ? clientError.url : "unknown",
40
- method:
41
- typeof clientError.method === "string"
42
- ? clientError.method
43
- : "unknown",
44
- data:
45
- clientError.responseData !== undefined
46
- ? clientError.responseData
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: "",