Commit 463733fc by Ryo Committed by GitHub

refactor: record message into otel body instead of attributes (#6911)

* fix: reduce otel log body duplication

* refactor: record message into otel body instead of attributes

* fix: trace and metric initialization

* fix: remove redundant traceId and spanId in the body
parent edbf57b2
......@@ -6,7 +6,6 @@ import {
getCurrentSpanContext,
getTracer
} from '@fastgpt-sdk/otel/tracing';
import { withContext } from '../logger';
import { serviceEnv } from '../../env';
type SpanAttributeValue = string | number | boolean;
......@@ -102,24 +101,14 @@ export async function withActiveSpan<T>(
attributes: normalizeAttributes(options.attributes)
},
async (span: SpanLike) => {
const spanContext = span.spanContext();
return withContext(
{
traceId: spanContext.traceId,
spanId: spanContext.spanId
},
async () => {
try {
return await callback(span);
} catch (error) {
setSpanError(span, error);
throw error;
} finally {
span.end();
}
}
);
try {
return await callback(span);
} catch (error) {
setSpanError(span, error);
throw error;
} finally {
span.end();
}
}
);
}
......
Subproject commit f1062d7ebf01ddb991f0c2dfead9f6d581eaed65
Subproject commit 91c9337cb64290f7fbaf68939545e432ddacbc7a
......@@ -32,6 +32,8 @@ export async function registerNodeInstrumentation() {
{ instrumentationCheck },
{ getErrText },
{ configureLogger, getLogger, LogCategories },
{ configureMetrics },
{ configureTracing },
{ InitialErrorEnum }
] = await Promise.all([
import('@fastgpt/service/common/mongo/init'),
......@@ -53,13 +55,16 @@ export async function registerNodeInstrumentation() {
import('@/service/common/system/health'),
import('@fastgpt/global/common/error/utils'),
import('@fastgpt/service/common/logger'),
import('@fastgpt/service/common/metrics'),
import('@fastgpt/service/common/tracing'),
import('@fastgpt/service/common/system/constants')
]);
await runInitializationStep({
step: 'configure-logger',
action: () => configureLogger()
});
await Promise.all([
runInitializationStep({ step: 'configure-tracing', action: () => configureTracing() }),
runInitializationStep({ step: 'configure-metrics', action: () => configureMetrics() }),
runInitializationStep({ step: 'configure-logger', action: () => configureLogger() })
]);
const logger = getLogger(LogCategories.SYSTEM);
logger.info('Starting system initialization...');
......
......@@ -80,6 +80,14 @@ import { configureTracingFromEnv, getTracer } from '@fastgpt-sdk/otel/tracing';
- `OTEL_TRACES_SAMPLER`
- `OTEL_TRACES_SAMPLER_ARG`
## logger OTel 输出
OTel logger 默认会把日志事件写成结构化 `body`,把业务上下文保留在 body 中,`attributes` 只保留日志元信息。
- `body` 形如 `{ __log_message, ...properties }``__log_message` 是人读摘要,properties 是结构化上下文。
- `attributes` 不再复制 properties,只保留 `category` 等日志元信息,避免 body 与 attributes 重复。
- 遇到 Map、Set、Error、BigInt、循环引用、class instance 等 JS 特有值时,会先安全归一成普通对象/数组/标量,避免出现 `[object Object]` 或序列化异常。
## 说明
- 这个包当前是“整理好的统一入口”,不是“已经迁移完成的替换方案”。
......
......@@ -30,6 +30,8 @@
"scripts": {
"build": "tsdown",
"dev": "tsdown --watch",
"lint": "eslint .",
"test": "vitest run --config vitest.config.ts",
"prepublishOnly": "pnpm build"
},
"keywords": [
......
export { configureLogger, disposeLogger, getLogger } from './client';
export { withContext, withCategoryPrefix } from '@logtape/logtape';
export { getOpenTelemetrySink } from './otel';
export type {
BodyFormatter,
ExceptionAttributeMode,
ObjectRenderer,
OpenTelemetrySink,
OpenTelemetrySinkOptions
} from './otel';
export type { OpenTelemetrySink, OpenTelemetrySinkOptions } from './otel';
export type {
ConsoleLoggerOptions,
LogCategory,
......
import type { LogRecord as OTelLogRecord } from '@opentelemetry/api-logs';
import { describe, expect, it } from 'vitest';
import { createLoggerOptionsFromEnv } from '../../src/logger/env';
import { getOpenTelemetrySink } from '../../src/logger/otel';
function createRecord(overrides: Partial<import('@logtape/logtape').LogRecord> = {}) {
return {
category: ['test'],
level: 'info',
message: ['Message'],
rawMessage: 'Message',
timestamp: 1_700_000_000_000,
properties: {},
...overrides
} satisfies import('@logtape/logtape').LogRecord;
}
function createMemoryLoggerProvider(records: OTelLogRecord[]) {
return {
getLogger: () => ({
emit: (record: OTelLogRecord) => {
records.push(record);
}
})
};
}
describe('getOpenTelemetrySink', () => {
it('places structured properties in the log body without duplicating attributes', () => {
const emitted: OTelLogRecord[] = [];
const sink = getOpenTelemetrySink({
loggerProvider: createMemoryLoggerProvider(emitted)
});
sink(
createRecord({
message: ['Completions body'],
rawMessage: 'Completions body',
properties: {
completionsBody: {
messages: [
{
role: 'user',
content: 'hello'
}
],
model: 'deepseek-v3.2',
stream: true
}
}
})
);
expect(emitted).toHaveLength(1);
expect(emitted[0]?.body).toEqual({
__log_message: 'Completions body',
completionsBody: {
messages: [
{
role: 'user',
content: 'hello'
}
],
model: 'deepseek-v3.2',
stream: true
}
});
expect(emitted[0]?.attributes).toEqual({
category: ['test']
});
});
it('keeps scalar placeholders in the structured body message', () => {
const emitted: OTelLogRecord[] = [];
const sink = getOpenTelemetrySink({
loggerProvider: createMemoryLoggerProvider(emitted)
});
sink(
createRecord({
message: ['User ', 'Ada', ' used ', 3, ' credits'],
rawMessage: 'User {user} used {count} credits',
properties: {
user: 'Ada',
count: 3
}
})
);
expect(emitted[0]?.body).toEqual({
__log_message: 'User Ada used 3 credits',
user: 'Ada',
count: 3
});
});
it('uses a fallback body for purely structured logs', () => {
const emitted: OTelLogRecord[] = [];
const sink = getOpenTelemetrySink({
loggerProvider: createMemoryLoggerProvider(emitted)
});
sink(
createRecord({
message: ['', { a: 1 }, ''],
rawMessage: '{*}',
properties: {
a: 1
}
})
);
expect(emitted[0]?.body).toEqual({
__log_message: 'structured log',
a: 1
});
expect(emitted[0]?.attributes?.a).toBeUndefined();
});
it('normalizes JS-specific values in the structured body', () => {
class CustomValue {
constructor(readonly id: string) {}
}
const emitted: OTelLogRecord[] = [];
const sink = getOpenTelemetrySink({
loggerProvider: createMemoryLoggerProvider(emitted)
});
const circular: Record<string, unknown> = { name: 'root' };
circular.self = circular;
sink(
createRecord({
properties: {
nested: {
map: new Map([['key', { value: 1 }]]),
set: new Set(['a', 'b']),
bigint: 1n,
custom: new CustomValue('custom-1'),
circular
}
}
})
);
expect((emitted[0]?.body as Record<string, unknown>).nested).toEqual({
map: {
key: {
value: 1
}
},
set: ['a', 'b'],
bigint: '1',
custom: {
__type: 'CustomValue',
id: 'custom-1'
},
circular: {
name: 'root',
self: '[Circular]'
}
});
});
it('normalizes top-level errors in the structured body', () => {
const emitted: OTelLogRecord[] = [];
const sink = getOpenTelemetrySink({
loggerProvider: createMemoryLoggerProvider(emitted)
});
const error = new Error('boom');
sink(
createRecord({
properties: {
error
}
})
);
const body = emitted[0]?.body as Record<string, Record<string, unknown>>;
expect(body.error?.name).toBe('Error');
expect(body.error?.message).toBe('boom');
expect(body.error?.stack).toEqual(expect.any(String));
});
it('keeps a property named message without overwriting the log message', () => {
const emitted: OTelLogRecord[] = [];
const sink = getOpenTelemetrySink({
loggerProvider: createMemoryLoggerProvider(emitted)
});
sink(
createRecord({
message: ['Request completed'],
rawMessage: 'Request completed',
properties: {
message: 'business message'
}
})
);
expect(emitted[0]?.body).toEqual({
__log_message: 'Request completed',
message: 'business message'
});
});
it('does not duplicate trace context fields in the structured body', () => {
const emitted: OTelLogRecord[] = [];
const sink = getOpenTelemetrySink({
loggerProvider: createMemoryLoggerProvider(emitted)
});
sink(
createRecord({
message: ['Workflow node run'],
rawMessage: 'Workflow node run',
properties: {
traceId: 'trace-id',
spanId: 'span-id',
requestId: 'request-id'
}
})
);
expect(emitted[0]?.body).toEqual({
__log_message: 'Workflow node run',
requestId: 'request-id'
});
});
});
describe('createLoggerOptionsFromEnv', () => {
it('creates OTel logger options without body or attribute modes', () => {
const options = createLoggerOptionsFromEnv({
env: {
LOG_ENABLE_OTEL: 'true'
},
defaultServiceName: 'test-service'
});
expect(options.otel && 'bodyMode' in options.otel).toBe(false);
expect(options.otel && 'attributeObjectMode' in options.otel).toBe(false);
});
});
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
pool: 'threads',
reporters: ['default']
}
});
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment