feat: manually mirror opencoze's code from bytedance

Change-Id: I09a73aadda978ad9511264a756b2ce51f5761adf
This commit is contained in:
fanlv
2025-07-20 17:36:12 +08:00
commit 890153324f
14811 changed files with 1923430 additions and 0 deletions

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// import { runtimeEnv } from '@coze-arch/bot-env/runtime';
const DEBUG_TAG = 'open_debug';
const OPEN_CONSOLE_MARK = new RegExp(`(?:\\?|\\&)${DEBUG_TAG}=true`);
export const shouldCloseConsole = () => {
// 如果URL带有调试开启标记则允许console打开
const { search } = window.location;
let isOpenDebug = !!sessionStorage.getItem(DEBUG_TAG);
if (!isOpenDebug) {
isOpenDebug = OPEN_CONSOLE_MARK.test(search);
isOpenDebug && sessionStorage.setItem(DEBUG_TAG, 'true');
}
// 除了正式正常环境都允许console打开
const isProduction = !!(IS_RELEASE_VERSION );
console.log('IS_RELEASE_VERSION', IS_RELEASE_VERSION, isProduction);
return !isOpenDebug && isProduction;
};

View File

@@ -0,0 +1,131 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ErrorBoundary as ReactErrorBoundary,
useErrorBoundary,
type ErrorBoundaryProps as ReactErrorBoundaryProps,
type ErrorBoundaryPropsWithRender,
type FallbackProps,
} from 'react-error-boundary';
import { type ErrorInfo, type ComponentType } from 'react';
import React, { useCallback, version } from 'react';
import { ApiError } from '../slardar/utils';
import { useLogger, type Logger } from '../logger';
// 拷贝自 react-error-boundary@3.1.4版本源码
function useErrorHandler(givenError?: unknown): (error: unknown) => void {
const [error, setError] = React.useState<unknown>(null);
if (givenError !== null && givenError !== undefined) {
throw givenError;
}
if (error !== null && error !== undefined) {
throw error;
}
return setError;
}
export type FallbackRender = ErrorBoundaryPropsWithRender['fallbackRender'];
export { useErrorBoundary, useErrorHandler, type FallbackProps };
export type ErrorBoundaryProps = ReactErrorBoundaryProps & {
/**
* @description componentDidCatch 触发该回调函数,参数透传自 componentDidCatch 的两个参数
* @param error 具体错误
* @param info
* @returns
*/
onError?: (error: Error, info: ErrorInfo) => void;
/**
* @description 可在该回调函数中重置组件的一些 state以避免一些错误的再次发生
* @param details reset 后
* @returns
*/
onReset?: (
details:
| { reason: 'imperative-api'; args: [] }
| { reason: 'keys'; prev: [] | undefined; next: [] | undefined },
) => void;
resetKeys?: [];
/**
* logger 实例。默认从 LoggerContext 中读取
*/
// logger?: Logger;
logger?: Logger;
/**
* 发生错误展示的兜底组件
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
FallbackComponent: ComponentType<FallbackProps>;
/**
* errorBoundaryName用于在发生错误时上报
* 事件react_error_collection / react_error_by_api_collection
*/
errorBoundaryName: string;
};
export const ErrorBoundary: React.FC<ErrorBoundaryProps> = ({
onError: propsOnError,
errorBoundaryName = 'unknown',
children,
logger: loggerInProps,
...restProps
}) => {
const loggerInContext = useLogger({ allowNull: true });
const logger = loggerInProps || loggerInContext;
if (!logger) {
console.warn(
`ErrorBoundary: not found logger instance in either props or context. errorBoundaryName: ${errorBoundaryName}`,
);
}
const onError = useCallback((error: Error, info: ErrorInfo) => {
const { componentStack } = info;
const meta = {
reportJsError: true, // 标记为 JS Error上报走 slardar.captureException
errorBoundaryName,
reactInfo: {
componentStack,
version,
},
};
if (error instanceof ApiError) {
logger?.persist.error({
eventName: 'react_error_by_api_collection',
error,
meta,
});
} else {
logger?.persist.error({
eventName: 'react_error_collection',
error,
meta,
});
}
propsOnError?.(error, info);
}, []);
return (
<ReactErrorBoundary {...restProps} onError={onError}>
{children}
</ReactErrorBoundary>
);
};

View File

@@ -0,0 +1,17 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/// <reference types='@coze-arch/bot-typings' />

View File

@@ -0,0 +1,43 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { reporter, Reporter } from './reporter';
// reporter 需要上报到 slardar 的方法导出
export type {
LoggerCommonProperties,
CustomEvent,
CustomErrorLog,
CustomLog,
ErrorEvent,
} from './reporter';
// console 控制台打印
export { logger, LoggerContext, Logger } from './logger';
// ErrorBoundary 相关方法
export {
ErrorBoundary,
useErrorBoundary,
useErrorHandler,
type ErrorBoundaryProps,
type FallbackProps,
} from './error-boundary';
export { SlardarReportClient, type SlardarInstance } from './slardar';
export { LogLevel } from './types';
export { getSlardarInstance, setUserInfoContext } from './slardar/runtime';

View File

@@ -0,0 +1,93 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isEmpty } from 'lodash-es';
import {
type LogOptions,
type LogLevel,
type LoggerReportClient,
type CommonLogOptions,
LogAction,
} from '../types';
export function getColorByLogLevel(type?: LogLevel) {
if (type === 'success') {
return '#00CC00';
} else if (type === 'warning') {
return '#CC9900';
} else if (type === 'error') {
return '#CC3333';
} else if (type === 'fatal') {
return '#FF0000';
} else {
return '#0099CC';
}
}
function doConsole(
{ namespace, scope, level, message, eventName, ...rest }: LogOptions,
...restArgs: unknown[]
) {
const logs: unknown[] = [
`%c Logger %c ${namespace ? namespace : level}${
scope ? ` %c ${scope}` : ''
} %c`,
'background:#444444; padding: 1px; border-radius: 3px 0 0 3px; color: #fff',
`background:${getColorByLogLevel(level)}; padding: 1px; border-radius: ${
scope ? '0' : '0 3px 3px 0'
}; color: #fff`,
scope
? 'background:#777777; padding: 1px; border-radius: 0 3px 3px 0; color: #fff; margin-left: -1px;'
: 'background:transparent',
];
if (scope) {
logs.push('background:transparent');
}
logs.push(eventName || message);
const payload = rest.error ? rest : rest.meta;
if (!isEmpty(payload)) {
logs.push(payload);
}
logs.push(...restArgs);
console.groupCollapsed(...logs);
}
export class ConsoleLogClient implements LoggerReportClient {
send({ meta, message, eventName, action, ...rest }: CommonLogOptions) {
const resolvedMsg = message
? message
: eventName
? `Event: ${eventName}`
: undefined;
if (!action?.includes(LogAction.CONSOLE) || !resolvedMsg) {
return;
}
const payload = { ...rest, message: resolvedMsg };
if (meta) {
doConsole(payload, meta);
} else {
doConsole(payload);
}
console.trace();
console.groupEnd();
}
}
export const consoleLogClient = new ConsoleLogClient();

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { createContext, useContext } from 'react';
import { type Logger } from './core';
// eslint-disable-next-line @typescript-eslint/naming-convention
export const LoggerContext = createContext<Logger | null>(null);
export function useLogger(options?: { allowNull?: boolean }) {
const { allowNull = false } = options || {};
const logger = useContext(LoggerContext);
if (allowNull !== true && !logger) {
throw new Error('expect logger in LoggerContext but not found');
}
return logger;
}

View File

@@ -0,0 +1,189 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isNil } from 'lodash-es';
import {
type CommonLogOptions,
LogAction,
type LoggerReportClient,
type BaseLoggerOptions,
type LogOptionsResolver,
LogLevel,
} from '../types';
import { LogOptionsHelper } from './log-options-helper';
import { consoleLogClient } from './console-client';
const defaultLogOptions = {
level: LogLevel.INFO,
action: [LogAction.CONSOLE],
};
function unwrapOptions(payload: string | CommonLogOptions): CommonLogOptions {
if (typeof payload === 'string') {
return {
message: payload,
};
}
return payload;
}
function resolveClients(
clients: LoggerReportClient[],
disableConsole?: boolean,
) {
const result = clients.includes(consoleLogClient)
? clients
: [consoleLogClient, ...clients];
if (disableConsole) {
return result.filter(item => item !== consoleLogClient);
}
return result;
}
export class BaseLogger<T extends CommonLogOptions = CommonLogOptions> {
ctx: LogOptionsHelper<CommonLogOptions>;
logOptionsResolvers: LogOptionsResolver[] = [];
disableConsole: boolean;
private clients: LoggerReportClient[];
constructor({
ctx = {},
clients = [],
beforeSend = [],
disableConsole,
}: BaseLoggerOptions) {
this.ctx = new LogOptionsHelper(ctx);
this.clients = clients;
this.logOptionsResolvers = beforeSend;
this.disableConsole = disableConsole || false;
}
addClient(client: LoggerReportClient) {
this.clients.push(client);
}
resolveCloneParams({
ctx,
clients = [],
beforeSend = [],
disableConsole,
}: BaseLoggerOptions) {
return {
// @ts-expect-error -- linter-disable-autofix
ctx: LogOptionsHelper.merge(this.ctx.get(), ctx),
clients: [...this.clients, ...clients],
beforeSend: [...this.logOptionsResolvers, ...beforeSend],
disableConsole: isNil(disableConsole)
? this.disableConsole
: disableConsole,
};
}
createLoggerWith<P extends CommonLogOptions = CommonLogOptions>(
options: BaseLoggerOptions,
) {
return new BaseLogger<P>(this.resolveCloneParams(options));
}
log(options: CommonLogOptions) {
const payload = LogOptionsHelper.merge(
defaultLogOptions,
this.ctx.get(),
options,
);
const resolvedPayload =
this.logOptionsResolvers.length > 0
? this.logOptionsResolvers.reduce(
(acc, cur) => (cur ? cur(acc) : acc),
{ ...payload },
)
: payload;
const resolvedClients = this.disableConsole
? this.clients.filter(item => item !== consoleLogClient)
: this.clients;
resolvedClients.forEach(client => {
client.send(resolvedPayload);
});
}
fatal(payload: T & { error: Error }) {
this.log({
...payload,
level: LogLevel.FATAL,
});
}
error(payload: T & { error: Error }) {
this.log({
...payload,
level: LogLevel.ERROR,
});
}
warning(payload: string | T) {
this.log({
...unwrapOptions(payload),
level: LogLevel.WARNING,
});
}
info(payload: string | T) {
this.log({
...unwrapOptions(payload),
level: LogLevel.INFO,
});
}
success(payload: string | T) {
this.log({
...unwrapOptions(payload),
level: LogLevel.SUCCESS,
});
}
}
export class Logger extends BaseLogger {
constructor({ clients = [], ...rest }: BaseLoggerOptions = {}) {
super({
...rest,
clients: resolveClients(clients, rest.disableConsole),
});
}
addClient(client: LoggerReportClient) {
super.addClient(client);
this.persist.addClient(client);
}
createLoggerWith(options: BaseLoggerOptions) {
return new Logger(this.resolveCloneParams(options));
}
persist = super.createLoggerWith<CommonLogOptions>({
ctx: {
action: [LogAction.CONSOLE, LogAction.PERSIST],
},
});
}

View File

@@ -0,0 +1,19 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { logger } from './logger';
export { Logger } from './core';
export { LoggerContext, useLogger } from './context';

View File

@@ -0,0 +1,63 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isEmpty } from 'lodash-es';
import { type CommonLogOptions } from '../types';
function mergeLogOption<T extends CommonLogOptions, P extends CommonLogOptions>(
source1: T,
source2: P,
) {
const { action: action1 = [], meta: meta1, ...rest1 } = source1;
const { action: action2 = [], meta: meta2, ...rest2 } = source2;
const meta = {
...meta1,
...meta2,
};
const res: CommonLogOptions = {
...rest1,
...rest2,
action: [...action1, ...action2],
...(isEmpty(meta) ? {} : { meta }),
};
return res;
}
export class LogOptionsHelper<T extends CommonLogOptions = CommonLogOptions> {
static merge<T extends CommonLogOptions>(...list: CommonLogOptions[]) {
return list.filter(Boolean).reduce((r, c) => mergeLogOption(r, c), {}) as T;
}
options: T;
constructor(options: T) {
this.options = options;
}
updateMeta(
updateCb: (
prevMeta?: Record<string, unknown>,
) => Record<string, unknown> | undefined,
) {
this.options.meta = updateCb(this.options.meta);
}
get() {
return this.options;
}
}

View File

@@ -0,0 +1,116 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
type CommonLogOptions,
type BaseLoggerOptions,
type LoggerReportClient,
} from '../types';
import { SlardarReportClient, type SlardarInstance } from '../slardar';
import { shouldCloseConsole } from '../console-disable';
import { Logger as RawLogger, type BaseLogger } from './core';
export type SetupKey = 'no-console';
export type SetupConfig = Record<SetupKey, unknown>;
export class Logger extends RawLogger {
private registeredInstance: Logger[] = [];
private slardarInstance: SlardarInstance | null = null;
static setupConfig: SetupConfig | null = null;
private setDisableConsole() {
if (!Logger.setupConfig?.['no-console']) {
return;
}
const disableConsole = shouldCloseConsole();
this.disableConsole = disableConsole;
if (this.persist) {
this.persist.disableConsole = disableConsole;
}
}
/**
* @deprecated logger方法仅作控制台打印用无需手动添加slardar client如需日志上报请使用`import { reporter } from '@coze-arch/logger',具体规范:
*/
addClient(client: LoggerReportClient): void {
super.addClient(client);
}
/**
* @deprecated 该方法已废弃,请统一使用`import { reporter } from '@coze-arch/logger'替换,具体规范:
*/
persist: BaseLogger<CommonLogOptions> = this.persist;
/**
* @deprecated logger方法仅作控制台打印用无需手动添加slardar client如需日志上报请使用`import { reporter } from '@coze-arch/logger',具体规范:
*/
init(slardarInstance: SlardarInstance) {
const client = new SlardarReportClient(slardarInstance);
this.persist?.addClient(client);
this.slardarInstance = client.slardarInstance;
this.registeredInstance.forEach(instance => {
instance.init(client.slardarInstance);
});
this.registeredInstance = [];
}
/**
* Setup some attributes of config of logger at any time
* @param setupConfig the config object needed to setup
*/
setup(config: SetupConfig) {
Logger.setupConfig = config;
}
createLoggerWith(options: BaseLoggerOptions): Logger {
const logger = new Logger(this.resolveCloneParams(options));
if (this.slardarInstance) {
logger.init(this.slardarInstance);
} else {
this.registeredInstance.push(logger);
}
return logger;
}
info(payload: string | CommonLogOptions): void {
this.setDisableConsole();
super.info(payload);
}
success(payload: string | CommonLogOptions): void {
this.setDisableConsole();
super.success(payload);
}
warning(payload: string | CommonLogOptions): void {
this.setDisableConsole();
super.warning(payload);
}
error(payload: CommonLogOptions & { error: Error }): void {
this.setDisableConsole();
super.error(payload);
}
}
const logger = new Logger({
clients: [],
ctx: {
meta: {},
},
});
export { logger };

View File

@@ -0,0 +1,56 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export type TracePointName = 'success' | 'fail' | string;
export interface TraceDuration {
points: TracePointName[];
interval: {
[key: TracePointName]: number;
};
}
export function genDurationTracer() {
const duration: TraceDuration = {
points: [],
interval: {},
};
const tracer = (pointName: TracePointName) => {
if (!pointName) {
return duration;
}
if (duration.points.indexOf(pointName) === -1) {
duration.points.push(pointName);
}
performance.mark(pointName);
if (duration.points.length > 1) {
const curIdx = duration.points.length - 1;
const measure = performance.measure(
'measure',
duration.points[curIdx - 1],
duration.points[curIdx],
);
duration.interval[pointName] = measure?.duration ?? 0;
}
return duration;
};
return {
tracer,
};
}

View File

@@ -0,0 +1,320 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { type CommonLogOptions, LogAction, LogLevel } from '../types';
import { SlardarReportClient, type SlardarInstance } from '../slardar';
import { Logger } from '../logger';
import { genDurationTracer, type TracePointName } from './duration-tracer';
export interface LoggerCommonProperties {
namespace?: string;
scope?: string;
}
export interface SlardarMeta {
meta?: Record<string, unknown>; // Combination of `categories` and `metrics`, check more:
}
export interface CustomLog extends SlardarMeta, LoggerCommonProperties {
message: string;
}
export type CustomErrorLog = CustomLog & { error: Error };
export interface CustomEvent<EventEnum extends string>
extends SlardarMeta,
LoggerCommonProperties {
eventName: EventEnum;
}
export interface ErrorEvent<EventEnum extends string>
extends CustomEvent<EventEnum> {
error: Error;
level?: 'error' | 'fatal';
}
export interface TraceEvent<EventEnum extends string>
extends LoggerCommonProperties {
eventName: EventEnum;
}
export interface TraceOptions extends SlardarMeta {
error?: Error;
}
type ReporterConfig = LoggerCommonProperties & SlardarMeta;
type LogType = 'info' | 'success' | 'warning' | 'error';
export class Reporter {
private initialized = false;
private logger: Logger;
private pendingQueue: CommonLogOptions[] = [];
private pendingInstance: Reporter[] = [];
public slardarInstance: SlardarInstance | null = null;
private log(type: LogType, payload: CommonLogOptions) {
if (!this.check(payload)) {
return;
}
this.logger.disableConsole = true;
this.logger[type](payload as CommonLogOptions & { error: Error });
this.logger.persist.disableConsole = true;
this.logger.persist[type](payload as CommonLogOptions & { error: Error });
}
constructor(config?: ReporterConfig) {
this.logger = new Logger({
clients: [],
ctx: {
...config,
},
});
}
/**
* 创建一个带有preset的reporter一般可以配置专属的`namespace`和`scope`
* @param preset
* @returns
*/
createReporterWithPreset(preset: ReporterConfig) {
const r = new Reporter(preset);
if (this.initialized) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
r.init(this.slardarInstance!);
} else {
this.pendingInstance.push(r);
}
return r;
}
/**
* 初始化reporter
* @param slardarInstance 需要上报的slardar实例
* @returns
*/
init(slardarInstance: SlardarInstance) {
if (!slardarInstance) {
console.warn('You should use reporter with a valid slardar instance');
return;
}
const slardarReportClient = new SlardarReportClient(slardarInstance);
this.slardarInstance = slardarReportClient.slardarInstance;
this.logger.persist.addClient(slardarReportClient);
this.initialized = true;
// Execute all pending items which are collected before initialization
this.pendingQueue.forEach(item => {
const levelFuncName: Omit<LogLevel, LogLevel.ERROR> =
item.level || LogLevel.INFO;
this.log(levelFuncName.toString() as LogType, item);
});
this.pendingQueue = [];
// Run init for all pending reporter instances
this.pendingInstance.forEach(instance => {
instance.init(slardarInstance);
});
this.pendingInstance = [];
}
getLogger() {
return this.logger;
}
/// Custom Log
/**
* 上报一个info日志
* @param event
* @returns
*/
info(log: CustomLog) {
this.log('info', log);
}
/**
* 上报一个success日志
* @param event
* @returns
*/
success(log: CustomLog) {
const info = this.formatCustomLog(log, LogLevel.SUCCESS);
this.log('success', info);
}
/**
* 上报一个warning日志
* @param event
* @returns
*/
warning(log: CustomLog) {
const info = this.formatCustomLog(log, LogLevel.WARNING);
this.log('warning', info);
}
/**
* 上报一个error日志
* @param event
* @returns
*/
error(log: CustomErrorLog) {
const info = this.formatCustomLog(
log,
LogLevel.ERROR,
) as CommonLogOptions & { error: Error };
this.log('error', info);
}
/// Custom Event
/**
* 上报一个自定义event事件
* @param event
* @returns
*/
event<EventEnum extends string>(event: CustomEvent<EventEnum>) {
const e = this.formatCustomEvent(event);
this.log('info', e);
}
/**
* 上报一个错误event事件LogLevel = 'error'
* @param event
* @returns
*/
errorEvent<EventEnum extends string>(event: ErrorEvent<EventEnum>) {
const e = this.formatErrorEvent(event) as CommonLogOptions & {
error: Error;
};
this.log('error', e);
}
/**
* 上报一个成功event事件LogLevel = 'success'
* @param event
* @returns
*/
successEvent<EventEnum extends string>(event: CustomEvent<EventEnum>) {
const e = this.formatCustomEvent(event) as CommonLogOptions;
this.log('success', e);
}
/// Trace Event
/**
* 性能追踪,可以记录一个流程中多个步骤间隔的耗时:
* @param event
* @returns
*/
tracer<EventEnum extends string>({ eventName }: TraceEvent<EventEnum>) {
const { tracer: durationTracer } = genDurationTracer();
const trace = (pointName: TracePointName, options: TraceOptions = {}) => {
const { meta, error } = options;
const e = this.formatCustomEvent({
eventName,
meta: {
...meta,
error,
duration: durationTracer(pointName),
},
});
if (!this.check(e)) {
return;
}
this.log('info', e);
};
return {
trace,
};
}
private check(info: CommonLogOptions) {
if (!this.initialized) {
// Initialization has not been called, collect the item into queue and consume it when called.
this.pendingQueue.push(info);
return false;
}
return true;
}
private formatCustomLog(
log: CustomLog | CustomErrorLog,
level: LogLevel,
): CommonLogOptions {
const {
namespace: ctxNamespace,
scope: ctxScope,
meta: ctxMeta = {},
} = this.logger.ctx?.options ?? {};
const { namespace, scope, meta = {}, message } = log;
return {
action: [LogAction.CONSOLE, LogAction.PERSIST],
namespace: namespace || ctxNamespace,
scope: scope || ctxScope,
level,
error: (log as CustomErrorLog).error,
message,
meta: {
...ctxMeta,
...meta,
},
};
}
private formatCustomEvent<EventEnum extends string>(
event: CustomEvent<EventEnum>,
): CommonLogOptions {
const {
namespace: ctxNamespace,
scope: ctxScope,
meta: ctxMeta = {},
} = this.logger.ctx?.options ?? {};
const { eventName, namespace, scope, meta = {} } = event;
return {
action: [LogAction.CONSOLE, LogAction.PERSIST],
namespace: namespace || ctxNamespace,
scope: scope || ctxScope,
eventName,
meta: {
...ctxMeta,
...meta,
},
};
}
private formatErrorEvent<EventEnum extends string>(
event: ErrorEvent<EventEnum>,
): CommonLogOptions {
const e = this.formatCustomEvent(event);
return {
...e,
meta: {
...e.meta,
// !NOTE: Slardar不支持`a.b`的字段的正则搜索(会报错),需要把`error.message`和`error.name`铺平放到第一层
errorMessage: event.error.message,
errorName: event.error.name,
level: event.level ?? 'error',
},
error: event.error,
};
}
}
const reporter = new Reporter();
export { reporter };

View File

@@ -0,0 +1,163 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { isNumber, isString, mapValues, omitBy, isNil } from 'lodash-es';
import type { SlardarInstance } from '@coze-studio/slardar-interface';
import {
type CommonLogOptions,
type LoggerReportClient,
LogAction,
LogLevel,
} from '../types';
import {
toFlatPropertyMap,
safeJson,
getErrorRecord,
getLogLevel,
getSlardarLevel,
} from './utils';
/**
* 将 meta 根据类型转换成
* - 指标 metrics可以被度量的值也就是数值
* - 维度 categories分类维度用来做筛选分组
*
* @param meta
* @returns
*/
function metaToMetricsCategories(meta?: Record<string, unknown>) {
const metrics: Record<string, number> = {};
const categories: Record<string, string> = {};
for (const k in meta) {
const val = meta[k];
if (isNumber(val)) {
metrics[k] = val;
} else {
categories[k] = isString(val) ? val : safeJson.stringify(val);
}
}
return {
metrics,
categories,
};
}
/**
* Record<string, unknown> => Record<string, string | number>
*/
function normalizeExtra(record: Record<string, unknown>) {
const result: Record<string, string | number> = {};
for (const k in record) {
const val = record[k];
if (isNumber(val) || isString(val)) {
result[k] = val;
} else {
result[k] = safeJson.stringify(val);
}
}
return result;
}
export class SlardarReportClient implements LoggerReportClient {
slardarInstance: SlardarInstance;
constructor(slardarInstance: SlardarInstance) {
// 业务项目里可能有多个 slardar 版本多个版本的类型不兼容constructor 里约束版本会存在问题 => 放开。
this.slardarInstance = slardarInstance;
if (!this.slardarInstance) {
console.warn('expected slardarInstance but get undefined/null');
}
}
send(options: CommonLogOptions) {
if (!options.action?.includes(LogAction.PERSIST)) {
// 非持久化日志,不上报
return;
}
const { level, message, action, eventName, meta, error, ...rest } = options;
// Slardar API
const resolveMeta = (inputs: Record<string, unknown>) =>
toFlatPropertyMap(
{
...rest,
...inputs,
error: getErrorRecord(error),
level: getLogLevel(level),
},
{
maxDepth: 4,
},
);
if (level === LogLevel.ERROR && meta?.reportJsError === true) {
const { reportJsError, reactInfo, ...restMeta } = meta || {};
const resolvedMeta = resolveMeta({
...restMeta,
message,
eventName,
});
// 上报 JS 异常
this.slardarInstance?.(
'captureException',
error,
omitBy(
mapValues(resolvedMeta, (v: unknown) =>
isString(v) ? v : safeJson.stringify(v),
),
isNil,
),
reactInfo as {
version: string;
componentStack: string;
},
);
} else if (eventName) {
const resolvedMeta = resolveMeta({
...meta,
});
const { metrics, categories } = metaToMetricsCategories(resolvedMeta);
// 上报独立的事件
this.slardarInstance?.('sendEvent', {
name: eventName,
metrics,
categories,
});
} else if (message) {
const resolvedMeta = resolveMeta({
...meta,
});
// 上报日志
this.slardarInstance?.('sendLog', {
level: getSlardarLevel(level),
content: message,
// slardar 内部会对 extra 处理分类number 类型的字段放入 metrics其他放入 categories
extra: normalizeExtra(resolvedMeta),
});
}
}
}
export type { SlardarInstance };

View File

@@ -0,0 +1,27 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { reporter } from '../reporter';
export const getSlardarInstance = () => reporter.slardarInstance;
// 异步设置 coze 的 uid 信息
export const setUserInfoContext = (userInfo: DataItem.UserInfo) => {
const slardarInstance = getSlardarInstance();
if (slardarInstance) {
slardarInstance?.('context.set', 'coze_uid', userInfo?.user_id_str);
}
};

View File

@@ -0,0 +1,175 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { LogLevel, type ApiErrorOption, ErrorType } from '../types';
function isObject<T>(it: T): it is object extends T
? // Narrow the `{}` type to an unspecified object
T & Record<string | number | symbol, unknown>
: unknown extends T
? // treat unknown like `{}`
T & Record<string | number | symbol, unknown>
: T extends object // function, array or actual object
? T extends readonly unknown[]
? never // not an array
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
T extends (...args: any[]) => any
? never // not a function
: T // no, an actual object
: never {
// This is necessary because:
// typeof null === 'object'
// typeof [] === 'object'
// [] instanceof Object === true
return Object.prototype.toString.call(it) === '[object Object]';
}
export function toFlatPropertyMap(
inputObj: Record<string, unknown>,
options?: {
keySeparator?: string;
maxDepth?: number;
},
) {
const { keySeparator = '.', maxDepth } = options || {};
const flattenRecursive = (
obj: Record<string, unknown>,
propertyMap: Record<string, unknown>,
depth = 1,
parentKey?: string,
// eslint-disable-next-line max-params
) => {
for (const [key, value] of Object.entries(obj)) {
const path = parentKey ? `${parentKey}${keySeparator}${key}` : key;
const currentDepth = depth + 1;
if (value && isObject(value) && (!maxDepth || currentDepth <= maxDepth)) {
flattenRecursive(value, propertyMap, currentDepth, path);
} else {
propertyMap[path] = value;
}
}
return propertyMap;
};
const result: Record<string, unknown> = {};
return flattenRecursive(inputObj, result);
}
export const safeJson = (() => {
const stringify = (sth: unknown): string => {
try {
return JSON.stringify(sth);
} catch (e) {
console.error(e);
return `JSON stringify Error: ${(e as Error).message}`;
}
};
const parse = (sth?: string) => {
try {
return JSON.parse(sth || '');
} catch (e) {
console.error(e);
return null;
}
};
return {
stringify,
parse,
};
})();
export class ApiError extends Error {
errorOption: ApiErrorOption;
constructor(option: ApiErrorOption) {
super(
`httpStatus=${option.httpStatus}, code=${option.code}, message=${option.message}, logId=${option.logId}`,
);
this.name = 'ApiError';
this.errorOption = option;
}
}
export const getErrorType = (error?: ApiError | Error): string => {
if (!error) {
return ErrorType.Unknown;
}
if (error instanceof ApiError) {
// 优先用业务给的 api error type
if (error.errorOption?.errorType) {
return error.errorOption.errorType;
}
return ErrorType.ApiError;
}
return ErrorType.Unknown;
};
export const getApiErrorRecord = (
error: ApiError | Error,
): Record<string, unknown> => {
if (error instanceof ApiError && error.errorOption) {
const { errorOption } = error;
return {
httpStatus: errorOption.httpStatus,
code: errorOption.code,
logId: errorOption.logId,
response: safeJson.stringify(errorOption.response),
requestConfig: safeJson.stringify(errorOption.requestConfig),
};
}
return {};
};
export const getErrorRecord = (
error?: ApiError | Error,
): Record<string, string | number | undefined> => {
if (!error) {
return {};
}
return {
...getApiErrorRecord(error),
message: error.message,
stack: error.stack,
type: getErrorType(error),
};
};
const levelMap = {
[LogLevel.INFO]: 'info',
[LogLevel.SUCCESS]: 'success',
[LogLevel.WARNING]: 'warn',
[LogLevel.ERROR]: 'error',
[LogLevel.FATAL]: 'fatal',
};
export function getLogLevel(level = LogLevel.INFO) {
return levelMap[level];
}
/** Slardar 自定义事件级别,默认是 info, 可枚举项 debug | info | warn | error */
const slardarLevelMap = {
[LogLevel.INFO]: 'info',
[LogLevel.SUCCESS]: 'info',
[LogLevel.WARNING]: 'warn',
[LogLevel.ERROR]: 'error',
[LogLevel.FATAL]: 'error',
};
export function getSlardarLevel(level = LogLevel.INFO) {
return slardarLevelMap[level];
}

View File

@@ -0,0 +1,168 @@
/*
* Copyright 2025 coze-dev Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* 日志级别
*/
export const enum LogLevel {
/**
* 日志
*/
INFO = 'info',
/**
* 成功日志
*/
SUCCESS = 'success',
/**
* 接口问题导致的错误
* 不影响用户使用的边缘 case
* 非核心功能问题
*/
WARNING = 'warning',
/**
* 严重错误
*/
ERROR = 'error',
/**
* 故障
*/
FATAL = 'fatal',
}
/**
* 日志动作,描述消费日志的行为
*/
export const enum LogAction {
/**
* 输出到浏览器控制台
*/
CONSOLE = 'console',
/**
* 持久化,即上报至平台
*/
PERSIST = 'persist',
}
/**
* 通用日志配置
*/
export interface CommonLogOptions {
/**
* 命名空间
*/
namespace?: string;
/**
* 作用域
* 层级namespace > scope
*/
scope?: string;
/**
* 日志级别
* @default LogLevel.INFO
*/
level?: LogLevel;
/**
* 日志动作,描述消费日志的行为
* @default [LogAction.CONSOLE]
*/
action?: LogAction[];
/**
* 日志消息
* 输出到浏览器控制台场景下必填。
* 最终输出到浏览器控制台: ${namespace} ${scope} ${message}
*/
message?: string;
/**
* 事件名
* 上报事件场景下必填。
*/
eventName?: string;
/**
* 扩展信息,可用于描述日志/事件的上下文信息
*/
meta?: Record<string, unknown>;
/**
* Error
* 错误日志/事件场景下必填
*/
error?: Error;
}
/**
* 上报平台 Client
*/
export interface LoggerReportClient {
send: (options: CommonLogOptions) => void;
}
export type LogOptionsResolver = (
options: CommonLogOptions,
) => CommonLogOptions;
export interface BaseLoggerOptions {
ctx?: CommonLogOptions;
clients?: LoggerReportClient[];
beforeSend?: LogOptionsResolver[];
disableConsole?: boolean;
}
// Make some properties required
type WithRequired<T, K extends keyof T> = T & { [P in K]-?: T[P] };
/**
* 美化输出,开发提效
* type A = { a: string };
* type B = { b: string };
* type C = A & B;
* type PrettyC = Pretty<A & B>;
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type Pretty<T extends Record<string, any>> = {
[key in keyof T]: T[key];
};
export type LogOptions = Pretty<WithRequired<CommonLogOptions, 'message'>>;
export const enum ErrorType {
/**
* API httpCode 非 200
*/
ApiError = 'ApiError',
/**
* API httpCode 200业务 Code 存在异常
*/
ApiBizError = 'ApiBizError',
/**
* 未归类的错误
*/
Unknown = 'Unknown',
}
export interface ApiErrorOption {
httpStatus: string;
/**
* 业务 code
*/
code?: string;
message?: string;
logId?: string;
requestConfig?: Record<string, unknown>;
response?: Record<string, unknown>;
/**
* 错误类型,用于细化监控
* @default ErrorType.ApiError
*/
errorType?: string;
}