Add JS remote logging package (#49702)

* Add remote logging package

* Update package.json

* Fix wca admin

* Add changefile(s) from automation for the following project(s): @woocommerce/remote-logging, @woocommerce/dependency-extraction-webpack-plugin, woocommerce

* Update .eslintrc.js

* Revert core changes

* Add tracks check and update tests

* Set hard limit to trace

* Fix filename

* Add filters to customise API endpoints

* Update REDAME.md

- Add filters
- Remove installation section

* Update REDAME.md

* Add composer.lock

* Fix filename

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Chi-Hsuan Huang 2024-08-01 12:35:43 +08:00 committed by GitHub
parent c9f20f87d2
commit 76e1761cf7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 3324 additions and 1393 deletions

View File

@ -18,6 +18,7 @@ module.exports = [
'@woocommerce/number',
'@woocommerce/product-editor',
'@woocommerce/tracks',
'@woocommerce/remote-logging',
// wc-blocks packages
'@woocommerce/blocks-checkout',
'@woocommerce/blocks-components',

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add JS remote logging package

View File

@ -0,0 +1,12 @@
module.exports = {
extends: [ 'plugin:@woocommerce/eslint-plugin/recommended' ],
root: true,
ignorePatterns: [ '**/test/*.ts', '**/test/*.tsx' ],
settings: {
'import/core-modules': [ '@woocommerce/settings' ],
'import/resolver': {
node: {},
typescript: {},
},
},
};

View File

@ -0,0 +1 @@
package-lock=false

View File

@ -0,0 +1,3 @@
# Changelog
This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

View File

@ -0,0 +1,80 @@
# WooCommerce Remote Logging
A remote logging package for Automattic based projects. This package provides error tracking and logging capabilities, with support for rate limiting, stack trace formatting, and customizable error filtering.
## Description
The WooCommerce Remote Logging package offers the following features:
- Remote error logging with stack trace analysis
- Customizable log severity levels
- Rate limiting to prevent API flooding
- Automatic capture of unhandled errors and promise rejections
- Filtering of errors based on WooCommerce asset paths
- Extensibility through WordPress filters
## Usage
1. Initialize the remote logger at the start of your application. If your plugin depends on WooCommerce plugin, the logger will be initialized in WooCommerce, so you don't need to call this function.
```js
import { init } from '@woocommerce/remote-logging';
init({
errorRateLimitMs: 60000 // Set rate limit to 1 minute
});
```
2. Log messages or errors:
```js
import { log } from '@woocommerce/remote-logging';
// Log an informational message
log('info', 'User completed checkout', { extra: { orderId: '12345' } });
// Log a warning
log('warning', 'API request failed, retrying...', { extra: { attempts: 3 } });
// Log an error
try {
// Some operation that might throw
} catch (error) {
log('error', 'Failed to process order', { extra: { error } });
}
```
## Customization
You can customize the behavior of the remote logger using WordPress filters:
- `woocommerce_remote_logging_should_send_error`: Control whether an error should be sent to the remote API.
- `woocommerce_remote_logging_error_data`: Modify the error data before sending it to the remote API.
- `woocommerce_remote_logging_log_endpoint`: Customize the endpoint URL for sending log messages.
- `woocommerce_remote_logging_js_error_endpoint`: Customize the endpoint URL for sending JavaScript errors.
### Example
```js
import { addFilter } from '@wordpress/hooks';
addFilter(
'woocommerce_remote_logging_should_send_error',
'my-plugin',
(shouldSend, error, stackFrames) => {
const containsPluginFrame = stackFrames.some(
( frame ) =>
frame.url && frame.url.includes( '/my-plugin/' );
);
// Custom logic to determine if the error should be sent
return shouldSend && containsPluginFrame;
}
);
```
### API Reference
- `init(config: RemoteLoggerConfig): void`: Initializes the remote logger with the given configuration.
- `log(severity: LogSeverity, message: string, extraData?: object): Promise<void>`: Logs a message with the specified severity and optional extra data.
For more detailed information about types and interfaces, refer to the source code and inline documentation.

View File

@ -0,0 +1,3 @@
module.exports = {
extends: '../internal-js-tests/babel.config.js',
};

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add JS remote logging package

View File

@ -0,0 +1,32 @@
{
"name": "woocommerce/remote-logging",
"description": "WooCommerce remote logger",
"type": "library",
"license": "GPL-3.0-or-later",
"minimum-stability": "dev",
"require-dev": {
"automattic/jetpack-changelogger": "3.3.0"
},
"config": {
"platform": {
"php": "7.4"
}
},
"extra": {
"changelogger": {
"formatter": {
"filename": "../../../tools/changelogger/class-package-formatter.php"
},
"types": {
"fix": "Fixes an existing bug",
"add": "Adds functionality",
"update": "Update existing functionality",
"dev": "Development related task",
"tweak": "A minor adjustment to the codebase",
"performance": "Address performance issues",
"enhancement": "Improve existing functionality"
},
"changelog": "CHANGELOG.md"
}
}
}

1059
packages/js/remote-logging/composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
{
"rootDir": "./",
"roots": [
"<rootDir>/src"
],
"preset": "./node_modules/@woocommerce/internal-js-tests/jest-preset.js"
}

View File

@ -0,0 +1,159 @@
{
"name": "@woocommerce/remote-logging",
"version": "0.0.1",
"description": "WooCommerce remote logging for Automattic based projects",
"author": "Automattic",
"license": "GPL-2.0-or-later",
"engines": {
"node": "^20.11.1",
"pnpm": "^9.1.0"
},
"keywords": [
"wordpress",
"woocommerce",
"remote-logging"
],
"homepage": "https://github.com/woocommerce/woocommerce/tree/trunk/packages/js/remote-logging/README.md",
"repository": {
"type": "git",
"url": "https://github.com/woocommerce/woocommerce.git"
},
"bugs": {
"url": "https://github.com/woocommerce/woocommerce/issues"
},
"main": "build/index.js",
"module": "build-module/index.js",
"types": "build-types",
"files": [
"build",
"build-module",
"build-types"
],
"scripts": {
"build": "pnpm --if-present --workspace-concurrency=Infinity --stream --filter=\"$npm_package_name...\" '/^build:project:.*$/'",
"build:project": "pnpm --if-present '/^build:project:.*$/'",
"build:project:cjs": "wireit",
"build:project:esm": "wireit",
"changelog": "composer install && composer exec -- changelogger",
"lint": "pnpm --if-present '/^lint:lang:.*$/'",
"lint:fix": "pnpm --if-present '/^lint:fix:lang:.*$/'",
"lint:fix:lang:js": "eslint src --fix",
"lint:lang:js": "eslint src",
"prepack": "pnpm build",
"test:js": "jest --config ./jest.config.json --passWithNoTests",
"watch:build": "pnpm --if-present --workspace-concurrency=Infinity --filter=\"$npm_package_name...\" --parallel '/^watch:build:project:.*$/'",
"watch:build:project": "pnpm --if-present run '/^watch:build:project:.*$/'",
"watch:build:project:cjs": "wireit",
"watch:build:project:esm": "wireit"
},
"lint-staged": {
"*.(t|j)s?(x)": [
"pnpm lint:fix",
"pnpm test-staged"
]
},
"dependencies": {
"@wordpress/hooks": "wp-6.0",
"debug": "^4.3.4",
"tracekit": "^0.4.6"
},
"devDependencies": {
"@babel/core": "^7.23.5",
"@types/debug": "^4.1.12",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.68",
"@woocommerce/eslint-plugin": "workspace:*",
"@woocommerce/internal-js-tests": "workspace:*",
"@wordpress/jest-console": "^5.4.0",
"concurrently": "^7.6.0",
"eslint": "^8.55.0",
"jest": "~27.5.1",
"jest-cli": "~27.5.1",
"rimraf": "5.0.5",
"ts-jest": "~29.1.1",
"typescript": "^5.3.3",
"wireit": "0.14.3"
},
"publishConfig": {
"access": "public"
},
"config": {
"ci": {
"lint": {
"command": "lint",
"changes": "src/**/*.{js,jsx,ts,tsx}"
},
"tests": [
{
"name": "JavaScript",
"command": "test:js",
"changes": [
"jest.config.js",
"babel.config.js",
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"cascade": "test:js",
"events": [
"pull_request",
"push"
]
}
]
}
},
"wireit": {
"build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json",
"clean": "if-file-deleted",
"files": [
"tsconfig-cjs.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"output": [
"build"
],
"dependencies": [
"dependencyOutputs"
]
},
"watch:build:project:cjs": {
"command": "tsc --project tsconfig-cjs.json --watch",
"service": true
},
"build:project:esm": {
"command": "tsc --project tsconfig.json",
"clean": "if-file-deleted",
"files": [
"tsconfig.json",
"src/**/*.{js,jsx,ts,tsx}",
"typings/**/*.ts"
],
"output": [
"build-module",
"build-types"
],
"dependencies": [
"dependencyOutputs"
]
},
"watch:build:project:esm": {
"command": "tsc --project tsconfig.json --watch",
"service": true
},
"dependencyOutputs": {
"allowUsuallyExcludedPaths": true,
"files": [
"node_modules/@woocommerce/internal-js-tests/build",
"node_modules/@woocommerce/internal-js-tests/build-module",
"node_modules/@woocommerce/internal-js-tests/jest-preset.js",
"node_modules/@woocommerce/eslint-plugin/configs",
"node_modules/@woocommerce/eslint-plugin/rules",
"node_modules/@woocommerce/eslint-plugin/index.js",
"package.json"
]
}
}
}

View File

@ -0,0 +1,9 @@
export {
init,
log,
REMOTE_LOGGING_SHOULD_SEND_ERROR_FILTER,
REMOTE_LOGGING_ERROR_DATA_FILTER,
REMOTE_LOGGING_LOG_ENDPOINT_FILTER,
REMOTE_LOGGING_JS_ERROR_ENDPOINT_FILTER,
} from './remote-logger';
export * from './types';

View File

@ -0,0 +1,372 @@
/**
* External dependencies
*/
import debugFactory from 'debug';
import { getSetting } from '@woocommerce/settings';
import TraceKit from 'tracekit';
import { applyFilters } from '@wordpress/hooks';
/**
* Internal dependencies
*/
import { mergeLogData } from './utils';
import { LogData, ErrorData, RemoteLoggerConfig } from './types';
const debug = debugFactory( 'wc:remote-logging' );
const warnLog = ( message: string ) => {
// eslint-disable-next-line no-console
console.warn( 'RemoteLogger: ' + message );
};
const errorLog = ( message: string, ...args: unknown[] ) => {
// eslint-disable-next-line no-console
console.error( 'RemoteLogger: ' + message, ...args );
};
export const REMOTE_LOGGING_SHOULD_SEND_ERROR_FILTER =
'woocommerce_remote_logging_should_send_error';
export const REMOTE_LOGGING_ERROR_DATA_FILTER =
'woocommerce_remote_logging_error_data';
export const REMOTE_LOGGING_LOG_ENDPOINT_FILTER =
'woocommerce_remote_logging_log_endpoint';
export const REMOTE_LOGGING_JS_ERROR_ENDPOINT_FILTER =
'woocommerce_remote_logging_js_error_endpoint';
const REMOTE_LOGGING_LAST_ERROR_SENT_KEY =
'wc_remote_logging_last_error_sent_time';
const DEFAULT_LOG_DATA: LogData = {
message: '',
feature: 'woocommerce_core',
host: window.location.hostname,
tags: [ 'woocommerce', 'js' ],
properties: {
wp_version: getSetting( 'wpVersion' ),
wc_version: getSetting( 'wcVersion' ),
},
};
export class RemoteLogger {
private config: RemoteLoggerConfig;
private lastErrorSentTime = 0;
public constructor( config: RemoteLoggerConfig ) {
this.config = config;
this.lastErrorSentTime = parseInt(
localStorage.getItem( REMOTE_LOGGING_LAST_ERROR_SENT_KEY ) || '0',
10
);
}
/**
* Logs a message to Logstash.
*
* @param severity - The severity of the log.
* @param message - The message to log.
* @param extraData - Optional additional data to include in the log.
*/
public async log(
severity: Exclude< LogData[ 'severity' ], undefined >,
message: string,
extraData?: Partial< Exclude< LogData, 'message' | 'severity' > >
) {
if ( ! message ) {
debug( 'Empty message' );
return;
}
const logData: LogData = mergeLogData( DEFAULT_LOG_DATA, {
message,
severity,
...extraData,
} );
await this.sendLog( logData );
}
/**
* Initializes error event listeners for catching unhandled errors and unhandled rejections.
*/
public initializeErrorHandlers(): void {
window.addEventListener( 'error', ( event ) => {
debug( 'Caught error event:', event );
this.handleError( event.error ).catch( ( error ) => {
debug( 'Failed to handle error:', error );
} );
} );
window.addEventListener( 'unhandledrejection', async ( event ) => {
debug( 'Caught unhandled rejection:', event );
try {
const error =
typeof event.reason === 'string'
? new Error( event.reason )
: event.reason;
await this.handleError( error );
} catch ( error ) {
debug( 'Failed to handle unhandled rejection:', error );
}
} );
}
/**
* Sends a log entry to the remote API.
*
* @param logData - The log data to be sent.
*/
private async sendLog( logData: LogData ): Promise< void > {
const body = new window.FormData();
body.append( 'params', JSON.stringify( logData ) );
try {
debug( 'Sending log to API:', logData );
/**
* Filters the Log API endpoint URL.
*
* @param {string} endpoint The default Log API endpoint URL.
*/
const endpoint = applyFilters(
REMOTE_LOGGING_LOG_ENDPOINT_FILTER,
'https://public-api.wordpress.com/rest/v1.1/logstash'
) as string;
await window.fetch( endpoint, {
method: 'POST',
body,
} );
} catch ( error ) {
// eslint-disable-next-line no-console
console.error( 'Failed to send log to API:', error );
}
}
/**
* Handles an error and prepares it for sending to the remote API.
*
* @param error - The error to handle.
*/
private async handleError( error: Error ) {
const currentTime = Date.now();
if (
currentTime - this.lastErrorSentTime <
this.config.errorRateLimitMs
) {
debug( 'Rate limit reached. Skipping send error', error );
return;
}
const trace = TraceKit.computeStackTrace( error );
if ( ! this.shouldSendError( error, trace.stack ) ) {
debug( 'Skipping error:', error );
return;
}
const errorData: ErrorData = {
...mergeLogData( DEFAULT_LOG_DATA, {
message: error.message,
severity: 'critical',
tags: [ 'js-unhandled-error' ],
} ),
trace: this.getFormattedStackFrame( trace ),
};
/**
* This filter allows to modify the error data before sending it to the remote API.
*
* @filter woocommerce_remote_logging_error_data
* @param {ErrorData} errorData The error data to be sent.
*/
const filteredErrorData = applyFilters(
REMOTE_LOGGING_ERROR_DATA_FILTER,
errorData
) as ErrorData;
try {
await this.sendError( filteredErrorData );
} catch ( _error ) {
// eslint-disable-next-line no-console
console.error( 'Failed to send error:', _error );
}
}
/**
* Sends an error to the remote API.
*
* @param error - The error data to be sent.
*/
private async sendError( error: ErrorData ) {
const body = new window.FormData();
body.append( 'error', JSON.stringify( error ) );
try {
debug( 'Sending error to API:', error );
/**
* Filters the JS error endpoint URL.
*
* @param {string} endpoint The default JS error endpoint URL.
*/
const endpoint = applyFilters(
REMOTE_LOGGING_JS_ERROR_ENDPOINT_FILTER,
'https://public-api.wordpress.com/rest/v1.1/js-error'
) as string;
await window.fetch( endpoint, {
method: 'POST',
body,
} );
} catch ( _error: unknown ) {
// eslint-disable-next-line no-console
console.error( 'Failed to send error to API:', _error );
} finally {
this.lastErrorSentTime = Date.now();
localStorage.setItem(
REMOTE_LOGGING_LAST_ERROR_SENT_KEY,
this.lastErrorSentTime.toString()
);
}
}
/**
* Limits the stack trace to 10 frames and formats it.
*
* @param stackTrace - The stack trace to format.
* @return The formatted stack trace.
*/
private getFormattedStackFrame( stackTrace: TraceKit.StackTrace ) {
const trace = stackTrace.stack
.slice( 0, 10 )
.map( this.getFormattedFrame )
.join( '\n\n' );
// Set hard limit of 8192 characters for the stack trace so it does not use too much user bandwith and also our computation.
return trace.length > 8192 ? trace.substring( 0, 8192 ) : trace;
}
/**
* Formats a single stack frame.
*
* @param frame - The stack frame to format.
* @param index - The index of the frame in the stack.
* @return The formatted stack frame.
*/
private getFormattedFrame( frame: TraceKit.StackFrame, index: number ) {
// Format the function name
const funcName =
frame.func !== '?' ? frame.func.replace( /"/g, '' ) : 'anonymous';
// Format the URL
const url = frame.url.replace( /"/g, '' );
// Format the context. Limit to 256 characters.
const context = frame.context
? frame.context
.map( ( line ) =>
line.replace( /^"|"$/g, '' ).replace( /\\"/g, '"' )
)
.filter( ( line ) => line.trim() !== '' )
.join( '\n ' )
.substring( 0, 256 )
: '';
// Construct the formatted string
return (
`#${ index + 1 } at ${ funcName } (${ url }:${ frame.line }:${
frame.column
})` + ( context ? `\n${ context }` : '' )
);
}
/**
* Determines whether an error should be sent to the remote API.
*
* @param error - The error to check.
* @param stackFrames - The stack frames of the error.
* @return Whether the error should be sent.
*/
private shouldSendError(
error: Error,
stackFrames: TraceKit.StackFrame[]
) {
const containsWooCommerceFrame = stackFrames.some(
( frame ) =>
frame.url && frame.url.includes( '/woocommerce/assets/' )
);
/**
* This filter allows to control whether an error should be sent to the remote API.
*
* @filter woocommerce_remote_logging_should_send_error
* @param {boolean} shouldSendError Whether the error should be sent.
* @param {Error} error The error object.
* @param {TraceKit.StackFrame[]} stackFrames The stack frames of the error.
*
*/
return applyFilters(
REMOTE_LOGGING_SHOULD_SEND_ERROR_FILTER,
containsWooCommerceFrame,
error,
stackFrames
) as boolean;
}
}
let logger: RemoteLogger | null = null;
/**
* Initializes the remote logging and error handlers.
* This function should be called once at the start of the application.
*
* @param config - Configuration object for the RemoteLogger.
*
*/
export function init( config: RemoteLoggerConfig ): void {
if ( ! window.wcTracks || ! window.wcTracks.isEnabled ) {
debug( 'Tracks is not enabled.' );
return;
}
if ( logger ) {
warnLog( 'RemoteLogger is already initialized.' );
return;
}
try {
logger = new RemoteLogger( config );
logger.initializeErrorHandlers();
} catch ( error ) {
errorLog( 'Failed to initialize RemoteLogger:', error );
}
}
/**
* Logs a message or error, respecting rate limiting.
*
* This function is inefficient because the data goes over the REST API, so use sparingly.
*
* @param severity - The severity of the log.
* @param message - The message to log.
* @param extraData - Optional additional data to include in the log.
*/
export async function log(
severity: Exclude< LogData[ 'severity' ], undefined >,
message: string,
extraData?: Partial< Exclude< LogData, 'message' | 'severity' > >
) {
if ( ! window.wcTracks || ! window.wcTracks.isEnabled ) {
debug( 'Tracks is not enabled.' );
return;
}
if ( ! logger ) {
warnLog( 'RemoteLogger is not initialized. Call init() first.' );
return;
}
try {
await logger.log( severity, message, extraData );
} catch ( error ) {
errorLog( 'Failed to send log:', error );
}
}

View File

@ -0,0 +1,7 @@
export const fetchMock = jest.fn().mockResolvedValue( {
ok: true,
json: () => Promise.resolve( {} ),
} );
// eslint-disable-next-line @typescript-eslint/no-explicit-any
global.fetch = fetchMock as any;

View File

@ -0,0 +1,288 @@
/**
* External dependencies
*/
import '@wordpress/jest-console';
import { addFilter, removeFilter } from '@wordpress/hooks';
/**
* Internal dependencies
*/
import { init, log } from '../';
import {
RemoteLogger,
REMOTE_LOGGING_SHOULD_SEND_ERROR_FILTER,
REMOTE_LOGGING_ERROR_DATA_FILTER,
REMOTE_LOGGING_LOG_ENDPOINT_FILTER,
REMOTE_LOGGING_JS_ERROR_ENDPOINT_FILTER,
} from '../remote-logger';
import { fetchMock } from './__mocks__/fetch';
jest.mock( 'tracekit', () => ( {
computeStackTrace: jest.fn().mockReturnValue( {
name: 'Error',
message: 'Test error',
stack: [
{
url: 'http://example.com/woocommerce/assets/js/admin/app.min.js',
func: 'testFunction',
args: [],
line: 1,
column: 1,
},
],
} ),
} ) );
describe( 'RemoteLogger', () => {
const originalConsoleWarn = console.warn;
let logger: RemoteLogger;
beforeEach( () => {
jest.clearAllMocks();
localStorage.clear();
logger = new RemoteLogger( { errorRateLimitMs: 60000 } ); // 1 minute
} );
afterEach( () => {
removeFilter( REMOTE_LOGGING_SHOULD_SEND_ERROR_FILTER, 'test' );
} );
beforeAll( () => {
console.warn = jest.fn();
} );
afterAll( () => {
console.warn = originalConsoleWarn;
} );
describe( 'log', () => {
it( 'should send a log message to the API', async () => {
await logger.log( 'info', 'Test message' );
expect( fetchMock ).toHaveBeenCalledWith(
'https://public-api.wordpress.com/rest/v1.1/logstash',
expect.objectContaining( {
method: 'POST',
body: expect.any( FormData ),
} )
);
} );
it( 'should not send an empty message', async () => {
await logger.log( 'info', '' );
expect( fetchMock ).not.toHaveBeenCalled();
} );
it( 'should use the filtered Log endpoint', async () => {
const customEndpoint = 'https://custom-logstash.example.com';
addFilter(
REMOTE_LOGGING_LOG_ENDPOINT_FILTER,
'test',
() => customEndpoint
);
await logger.log( 'info', 'Test message' );
expect( fetchMock ).toHaveBeenCalledWith(
customEndpoint,
expect.objectContaining( {
method: 'POST',
body: expect.any( FormData ),
} )
);
removeFilter( REMOTE_LOGGING_LOG_ENDPOINT_FILTER, 'test' );
} );
} );
describe( 'handleError', () => {
it( 'should send an error to the API', async () => {
const error = new Error( 'Test error' );
await ( logger as any ).handleError( error );
expect( fetchMock ).toHaveBeenCalledWith(
'https://public-api.wordpress.com/rest/v1.1/js-error',
expect.objectContaining( {
method: 'POST',
body: expect.any( FormData ),
} )
);
} );
it( 'should respect rate limiting', async () => {
addFilter(
REMOTE_LOGGING_SHOULD_SEND_ERROR_FILTER,
'test',
() => true
);
const error = new Error( 'Test error - rate limit' );
await ( logger as any ).handleError( error );
await ( logger as any ).handleError( error );
expect( fetchMock ).toHaveBeenCalledTimes( 1 );
} );
it( 'should filter error data', async () => {
const filteredErrorData = {
message: 'Filtered test error',
severity: 'warning',
tags: [ 'filtered-tag' ],
trace: 'Filtered stack trace',
};
addFilter( REMOTE_LOGGING_ERROR_DATA_FILTER, 'test', ( data ) => {
return filteredErrorData;
} );
// mock sendError to return true
const sendErrorSpy = jest
.spyOn( logger as any, 'sendError' )
.mockImplementation( () => {} );
const error = new Error( 'Test error' );
await ( logger as any ).handleError( error );
expect( sendErrorSpy ).toHaveBeenCalledWith( filteredErrorData );
} );
it( 'should use the filtered JS error endpoint', async () => {
const customEndpoint = 'https://custom-js-error.example.com';
addFilter(
REMOTE_LOGGING_JS_ERROR_ENDPOINT_FILTER,
'test',
() => customEndpoint
);
const error = new Error( 'Test error' );
await ( logger as any ).handleError( error );
expect( fetchMock ).toHaveBeenCalledWith(
customEndpoint,
expect.objectContaining( {
method: 'POST',
body: expect.any( FormData ),
} )
);
removeFilter( REMOTE_LOGGING_JS_ERROR_ENDPOINT_FILTER, 'test' );
} );
} );
describe( 'shouldSendError', () => {
it( 'should return true for WooCommerce errors', () => {
const error = new Error( 'Test error' );
const stackFrames = [
{
url: 'http://example.com/wp-content/plugins/woocommerce/assets/js/admin/app.min.js',
func: 'testFunction',
args: [],
line: 1,
column: 1,
},
];
const result = ( logger as any ).shouldSendError(
error,
stackFrames
);
expect( result ).toBe( true );
} );
it( 'should return false for non-WooCommerce errors', () => {
const error = new Error( 'Test error' );
const stackFrames = [
{
url: 'http://example.com/other/script.js',
func: 'testFunction',
args: [],
line: 1,
column: 1,
},
];
const result = ( logger as any ).shouldSendError(
error,
stackFrames
);
expect( result ).toBe( false );
} );
it( 'should return false for WooCommerce errors with no stack frames', () => {
const error = new Error( 'Test error' );
const result = ( logger as any ).shouldSendError( error, [] );
expect( result ).toBe( false );
} );
it( 'should return true if filter returns true', () => {
addFilter(
REMOTE_LOGGING_SHOULD_SEND_ERROR_FILTER,
'test',
() => true
);
const error = new Error( 'Test error' );
const result = ( logger as any ).shouldSendError( error, [] );
expect( result ).toBe( true );
} );
} );
describe( 'getFormattedStackFrame', () => {
it( 'should format stack frames correctly', () => {
const stackTrace = {
name: 'Error',
message: 'Test error',
stack: [
{
url: 'http://example.com/woocommerce/assets/js/admin/wc-admin.min.js',
func: 'testFunction',
args: [],
line: 1,
column: 1,
context: [
'const x = 1;',
'throw new Error("Test error");',
'const y = 2;',
],
},
],
};
const result = ( logger as any ).getFormattedStackFrame(
stackTrace
);
expect( result ).toContain(
'#1 at testFunction (http://example.com/woocommerce/assets/js/admin/wc-admin.min.js:1:1)'
);
expect( result ).toContain( 'const x = 1;' );
expect( result ).toContain( 'throw new Error("Test error");' );
expect( result ).toContain( 'const y = 2;' );
} );
} );
} );
describe( 'init', () => {
beforeEach( () => {
jest.clearAllMocks();
window.wcTracks = { isEnabled: true };
} );
it( 'should not initialize the logger if Tracks is not enabled', () => {
window.wcTracks = { isEnabled: false };
init( { errorRateLimitMs: 1000 } );
expect( () => log( 'info', 'Test message' ) ).not.toThrow();
} );
it( 'should initialize the logger if Tracks is enabled', () => {
init( { errorRateLimitMs: 1000 } );
expect( () => log( 'info', 'Test message' ) ).not.toThrow();
} );
it( 'should not initialize the logger twice', () => {
init( { errorRateLimitMs: 1000 } );
init( { errorRateLimitMs: 2000 } );
expect( console ).toHaveWarnedWith(
'RemoteLogger: RemoteLogger is already initialized.'
);
} );
} );
describe( 'log', () => {
it( 'should not log if Tracks is not enabled', () => {
window.wcTracks = { isEnabled: false };
log( 'info', 'Test message' );
expect( fetchMock ).not.toHaveBeenCalled();
} );
} );

View File

@ -0,0 +1,126 @@
/**
* Internal dependencies
*/
import { mergeLogData } from '../utils';
import { LogData } from '../types';
describe( 'mergeLogData', () => {
it( 'should merge basic properties', () => {
const target: LogData = {
message: 'Target message',
feature: 'target_feature',
severity: 'info',
};
const source: Partial< LogData > = {
message: 'Source message',
severity: 'error',
};
const result = mergeLogData( target, source );
expect( result ).toEqual( {
message: 'Source message',
feature: 'target_feature',
severity: 'error',
} );
} );
it( 'should merge extra properties', () => {
const target: LogData = {
message: 'Test',
extra: { a: 1, b: 2 },
};
const source: Partial< LogData > = {
extra: { b: 3, c: 4 },
};
const result = mergeLogData( target, source );
expect( result.extra ).toEqual( { a: 1, b: 3, c: 4 } );
} );
it( 'should merge properties', () => {
const target: LogData = {
message: 'Test',
properties: { x: 'a', y: 'b' },
};
const source: Partial< LogData > = {
properties: { y: 'c', z: 'd' },
};
const result = mergeLogData( target, source );
expect( result.properties ).toEqual( { x: 'a', y: 'c', z: 'd' } );
} );
it( 'should concatenate tags', () => {
const target: LogData = {
message: 'Test',
tags: [ 'tag1', 'tag2' ],
};
const source: Partial< LogData > = {
tags: [ 'tag3', 'tag4' ],
};
const result = mergeLogData( target, source );
expect( result.tags ).toEqual( [ 'tag1', 'tag2', 'tag3', 'tag4' ] );
} );
it( 'should handle missing properties in source', () => {
const target: LogData = {
message: 'Target message',
feature: 'target_feature',
severity: 'info',
extra: { a: 1 },
properties: { x: 'a' },
tags: [ 'tag1' ],
};
const source: Partial< LogData > = {
message: 'Source message',
};
const result = mergeLogData( target, source );
expect( result ).toEqual( {
message: 'Source message',
feature: 'target_feature',
severity: 'info',
extra: { a: 1 },
properties: { x: 'a' },
tags: [ 'tag1' ],
} );
} );
it( 'should handle missing properties in target', () => {
const target: LogData = {
message: 'Target message',
};
const source: Partial< LogData > = {
feature: 'source_feature',
severity: 'error',
extra: { b: 2 },
properties: { y: 'b' },
tags: [ 'tag2' ],
};
const result = mergeLogData( target, source );
expect( result ).toEqual( {
message: 'Target message',
feature: 'source_feature',
severity: 'error',
extra: { b: 2 },
properties: { y: 'b' },
tags: [ 'tag2' ],
} );
} );
it( 'should not modify the original target object', () => {
const target: LogData = {
message: 'Target message',
extra: { a: 1 },
tags: [ 'tag1' ],
};
const source: Partial< LogData > = {
message: 'Source message',
extra: { b: 2 },
tags: [ 'tag2' ],
};
const result = mergeLogData( target, source );
expect( target ).toEqual( {
message: 'Target message',
extra: { a: 1 },
tags: [ 'tag1' ],
} );
expect( result ).not.toBe( target );
} );
} );

View File

@ -0,0 +1,47 @@
export type RemoteLoggerConfig = {
errorRateLimitMs: number; // in milliseconds
};
export type LogData = {
/**
* The message to log.
*/
message: string;
/**
* A feature slug. Defaults to 'woocommerce_core'. The feature must be added to the features list in API before using.
*/
feature?: string;
/**
* The severity of the log.
*/
severity?:
| 'emergency'
| 'alert'
| 'critical'
| 'error'
| 'warning'
| 'notice'
| 'info'
| 'debug';
/**
* The hostname of the client. Automatically set to the current hostname.
*/
host?: string;
/**
* Extra data to include in the log.
*/
extra?: unknown;
/**
* Tags to add to the log.
*/
tags?: string[];
/**
* Properties to add to the log. Unlike `extra`, it won't be serialized to a string.
*/
properties?: Record< string, unknown >;
};
export type ErrorData = LogData & {
trace: string;
};

View File

@ -0,0 +1,41 @@
/**
* Internal dependencies
*/
import { LogData } from './types';
/**
* Deeply merges two LogData objects.
*
* @param target - The target LogData object.
* @param source - The source LogData object to merge into the target.
* @return The merged LogData object.
*/
export function mergeLogData( target: LogData, source: Partial< LogData > ) {
const result = { ...target };
for ( const key in source ) {
if ( Object.prototype.hasOwnProperty.call( source, key ) ) {
const typedKey = key as keyof LogData;
if ( typedKey === 'extra' || typedKey === 'properties' ) {
result[ typedKey ] = {
...( target[ typedKey ] as object ),
...( source[ typedKey ] as object ),
};
} else if (
typedKey === 'tags' &&
Array.isArray( source[ typedKey ] )
) {
result[ typedKey ] = [
...( Array.isArray( target[ typedKey ] )
? ( target[ typedKey ] as string[] )
: [] ),
...( source[ typedKey ] as string[] ),
];
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
result[ typedKey ] = source[ typedKey ] as any;
}
}
}
return result;
}

View File

@ -0,0 +1,10 @@
{
"extends": "../tsconfig-cjs",
"compilerOptions": {
"outDir": "build",
"typeRoots": [
"./typings",
"./node_modules/@types"
]
}
}

View File

@ -0,0 +1,12 @@
{
"extends": "../tsconfig",
"include": [ "src/", "typings/" ],
"compilerOptions": {
"rootDir": "src",
"outDir": "build-module",
"declaration": true,
"declarationMap": true,
"declarationDir": "./build-types",
"typeRoots": [ "./typings", "./node_modules/@types" ]
}
}

View File

@ -0,0 +1,10 @@
declare global {
interface Window {
wcTracks: {
isEnabled: boolean;
};
}
}
/*~ If your module exports nothing, you'll need this line. Otherwise, delete it */
export {};

View File

@ -0,0 +1,12 @@
declare module '@woocommerce/settings' {
export declare function getAdminLink( path: string ): string;
export declare function getSetting< T >(
name: string,
fallback?: unknown,
filter?: ( val: unknown, fb: unknown ) => unknown
): T;
export declare function isWpVersion(
version: string,
operator: '>' | '>=' | '=' | '<' | '<='
): boolean;
}

File diff suppressed because it is too large Load Diff