Skip to content

Commit 6f15e18

Browse files
fangxiu-wfclaude
andauthored
feat(trace,sls): differentiate serviceNamePrefix for user vs managed backends (#167)
Managed (inner) trace and log backends can now report under a distinct service name from user backends, via a top-level `serviceNamePrefix` in configs/inner/data_config.json. Falls back to the user prefix when unset, so the default single-service-name behavior is unchanged. - OTLP trace: convert once per distinct service.name and fan out each group to its own backends (reuses the existing convertKey multi-state path). - SLS: per-endpoint service name injected into the __service_name__ tag at flush time. - Dedup keys (OTLP + SLS) include serviceName so a shared URL under two service names is kept as two distinct backends. Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 88d5797 commit 6f15e18

10 files changed

Lines changed: 313 additions & 65 deletions

File tree

docs/trace-output.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ Trace export sends the **same** converted spans to **every** configured backend
8282
8383
Backends are **deduplicated** by normalized endpoint URL + full request headers, so listing the same backend twice (e.g. once as a user backend and once as a managed backend) results in a single export. Two backends that share a URL but differ in auth headers / workspace / license are kept as distinct.
8484

85-
Shared vs. per-backend settings (because spans are converted once):
85+
Shared vs. per-backend settings (spans are converted once per distinct `service.name`):
8686

87-
- **Shared across all backends:** `serviceName`, `resourceAttributes`, `captureMessageContent`, `resourceAttributeKeys`, `maxExportBatchBytes`, `turnIdleTimeoutMs`.
88-
- **Per-backend:** endpoint URL, headers, compression.
87+
- **Shared across all backends:** `resourceAttributes`, `captureMessageContent`, `resourceAttributeKeys`, `maxExportBatchBytes`, `turnIdleTimeoutMs`.
88+
- **Per-backend:** endpoint URL, headers, compression, and `service.name` (see below — user vs. managed backends can differ).
8989

9090
A failing backend is isolated — it does not block the healthy backends, and its failed spans are persisted separately under `~/.loongsuite-pilot/logs/otlp-failed/<service>-<agent>__<backend-name>.jsonl`.
9191

@@ -96,6 +96,7 @@ Managed/hosted deployments can push extra trace backends via `~/.loongsuite-pilo
9696
```json
9797
{
9898
"sls": [ /* managed SLS endpoints (unchanged) */ ],
99+
"serviceNamePrefix": "managed-service",
99100
"otlp": [
100101
{
101102
"name": "team-collector",
@@ -118,6 +119,7 @@ Managed/hosted deployments can push extra trace backends via `~/.loongsuite-pilo
118119

119120
| Field | Applies to | Description |
120121
|-------|-----------|-------------|
122+
| `serviceNamePrefix` | top-level | Service-name prefix for **all** managed backends — trace (`otlp[]`/`cms[]`, as `service.name`) and log (`sls[]`, as the `__service_name__` tag) — keeping them distinct from user backends. Optional; falls back to the user `serviceNamePrefix` when omitted (no differentiation). |
121123
| `otlp[].name` / `cms[].name` | both | Label used in logs and in the per-backend failed-log filename. Optional (defaults to `inner-otlp-<i>` / `inner-cms-<i>`). |
122124
| `otlp[].endpoint` | otlp | OTLP HTTP base URL (`/v1/traces` auto-appended). |
123125
| `otlp[].headers` | otlp | Request headers (e.g. auth token). |
@@ -127,7 +129,7 @@ Managed/hosted deployments can push extra trace backends via `~/.loongsuite-pilo
127129
| `cms[].project` | cms | Sent as `x-arms-project`. Extracted from the endpoint hostname if omitted. |
128130
| `cms[].workspace` | cms | Sent as `x-cms-workspace`. |
129131

130-
Each `cms[]` entry is expanded into an OTLP endpoint with the corresponding `x-arms-*` / `x-cms-*` headers, and any CMS backend adds the `acs.arms.service.feature=genai_app` resource attribute (shared, as noted above). Malformed managed config (e.g. `otlp`/`cms` written as a non-array) is ignored rather than failing collection.
132+
Each `cms[]` entry is expanded into an OTLP endpoint with the corresponding `x-arms-*` / `x-cms-*` headers, and any CMS backend adds the `acs.arms.service.feature=genai_app` resource attribute (shared, as noted above). When `serviceNamePrefix` is set here, managed backends report under `<serviceNamePrefix>-<agent>` while user backends keep the user prefix; spans are then converted once per distinct `service.name` (typically twice — user and managed). Malformed managed config (e.g. `otlp`/`cms` written as a non-array) is ignored rather than failing collection.
131133

132134
## Backend Examples
133135

docs/zh-CN/trace-output.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,10 +82,10 @@ Trace 导出会把**同一批**转换后的 span **同时**发往**所有**已
8282
8383
后端会按"规范化 URL + 完整请求 headers"**去重**,因此把同一个后端列两遍(例如既作为用户后端又作为托管后端)只会导出一次;URL 相同但鉴权 header / workspace / license 不同的两个后端会被视为不同后端各自保留。
8484

85-
共享 vs 单后端设置(因为 span 只转换一次):
85+
共享 vs 单后端设置(span 按不同 `service.name` 各转换一次):
8686

87-
- **所有后端共享:** `serviceName``resourceAttributes``captureMessageContent``resourceAttributeKeys``maxExportBatchBytes``turnIdleTimeoutMs`
88-
- **每后端独立:** endpoint URL、headers、compression。
87+
- **所有后端共享:** `resourceAttributes``captureMessageContent``resourceAttributeKeys``maxExportBatchBytes``turnIdleTimeoutMs`
88+
- **每后端独立:** endpoint URL、headers、compression,以及 `service.name`(见下文——用户后端与托管后端可不同)
8989

9090
某个后端失败会被隔离——不会阻塞健康后端,其失败 span 会单独落盘到 `~/.loongsuite-pilot/logs/otlp-failed/<service>-<agent>__<后端名>.jsonl`
9191

@@ -96,6 +96,7 @@ Trace 导出会把**同一批**转换后的 span **同时**发往**所有**已
9696
```json
9797
{
9898
"sls": [ /* 托管 SLS endpoint(不变) */ ],
99+
"serviceNamePrefix": "managed-service",
99100
"otlp": [
100101
{
101102
"name": "team-collector",
@@ -118,6 +119,7 @@ Trace 导出会把**同一批**转换后的 span **同时**发往**所有**已
118119

119120
| 字段 | 适用 | 说明 |
120121
|------|------|------|
122+
| `serviceNamePrefix` | 顶层 | **所有**托管后端的 service name 前缀——trace(`otlp[]`/`cms[]`,作为 `service.name`)与 log(`sls[]`,作为 `__service_name__` tag)——用于与用户后端区分。可选;省略时回退到用户的 `serviceNamePrefix`(即不做区分)。 |
121123
| `otlp[].name` / `cms[].name` | 两者 | 用于日志与失败日志文件名的标签。可选(默认 `inner-otlp-<i>` / `inner-cms-<i>`)。 |
122124
| `otlp[].endpoint` | otlp | OTLP HTTP 基础 URL(自动补 `/v1/traces`)。 |
123125
| `otlp[].headers` | otlp | 请求 header(如鉴权 token)。 |
@@ -127,7 +129,7 @@ Trace 导出会把**同一批**转换后的 span **同时**发往**所有**已
127129
| `cms[].project` | cms | 作为 `x-arms-project` 发送。省略时从 endpoint 域名提取。 |
128130
| `cms[].workspace` | cms | 作为 `x-cms-workspace` 发送。 |
129131

130-
每个 `cms[]` 条目会展开为一个带 `x-arms-*` / `x-cms-*` header 的 OTLP 后端;只要存在 CMS 后端,就会附加 `acs.arms.service.feature=genai_app` 资源属性(如上所述为共享)。托管配置格式错误(例如 `otlp`/`cms` 写成非数组)会被忽略,而不会导致采集失败。
132+
每个 `cms[]` 条目会展开为一个带 `x-arms-*` / `x-cms-*` header 的 OTLP 后端;只要存在 CMS 后端,就会附加 `acs.arms.service.feature=genai_app` 资源属性(如上所述为共享)。此处设置 `serviceNamePrefix` 后,托管后端会以 `<serviceNamePrefix>-<agent>` 上报,用户后端仍用用户前缀;此时 span 会按不同 `service.name` 各转换一次(通常两次——用户与托管)。托管配置格式错误(例如 `otlp`/`cms` 写成非数组)会被忽略,而不会导致采集失败。
131133

132134
## 后端接入示例
133135

src/core/config-loader.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export interface InnerDataConfig {
5656
sls?: SlsEndpointEntry[];
5757
otlp?: OtlpEndpointEntry[];
5858
cms?: CmsEndpointEntry[];
59+
serviceNamePrefix?: string;
5960
}
6061

6162
/**
@@ -224,7 +225,11 @@ export async function loadConfig(): Promise<AnalyticsConfig> {
224225
cms: buildCmsConfig(file),
225226
otlpTrace: buildOtlpTraceRawConfig(file),
226227
innerTrace: innerDataConfig
227-
? { otlp: innerDataConfig.otlp, cms: innerDataConfig.cms }
228+
? {
229+
otlp: innerDataConfig.otlp,
230+
cms: innerDataConfig.cms,
231+
serviceNamePrefix: innerDataConfig.serviceNamePrefix,
232+
}
228233
: undefined,
229234
autoUpdate: buildAutoUpdateConfig(file),
230235

@@ -487,6 +492,13 @@ export function buildOtlpTraceConfig(config: AnalyticsConfig): OtlpTraceFlusherC
487492
const endpoints: OtlpEndpoint[] = [];
488493
// Collected from any ARMS/CMS backend; merged into the shared resource.
489494
const armsResourceAttributes: Record<string, string> = {};
495+
// User backends use the top-level (shared) serviceName. Managed backends may
496+
// override it via inner serviceNamePrefix; only tag them when it actually
497+
// differs, so the default case keeps user/inner backends dedup-identical and
498+
// leaves the flusher on its single-conversion path.
499+
const userServiceName = config.otlpTrace?.serviceName ?? (config.serviceNamePrefix || 'loongsuite-pilot');
500+
const innerPrefix = config.innerTrace?.serviceNamePrefix;
501+
const innerServiceName = innerPrefix && innerPrefix !== userServiceName ? innerPrefix : undefined;
490502

491503
// 1. User generic OTLP (endpoint via env or config).
492504
const userOtlpEndpoint = env('LOONGSUITE_PILOT_OTLP_ENDPOINT') ?? config.otlpTrace?.endpoint;
@@ -528,22 +540,25 @@ export function buildOtlpTraceConfig(config: AnalyticsConfig): OtlpTraceFlusherC
528540
endpoint: ep.endpoint,
529541
headers: ep.headers,
530542
compression: ep.compression,
543+
serviceName: innerServiceName,
531544
});
532545
});
533546

534547
// 4. Inner managed CMS/ARMS shorthand backends.
535548
const innerCms = Array.isArray(config.innerTrace?.cms) ? config.innerTrace!.cms : [];
536549
innerCms.forEach((ep, i) => {
537550
if (!ep.endpoint) return;
538-
endpoints.push(cmsEntryToOtlpEndpoint(ep.name ?? `inner-cms-${i}`, ep, armsResourceAttributes));
551+
endpoints.push(
552+
cmsEntryToOtlpEndpoint(ep.name ?? `inner-cms-${i}`, ep, armsResourceAttributes, innerServiceName),
553+
);
539554
});
540555

541556
const deduped = dedupOtlpEndpoints(endpoints);
542557
if (deduped.length === 0) return undefined;
543558

544559
const otlp = config.otlpTrace;
545560
const captureMessageContent = otlp?.captureMessageContent ?? resolveCaptureMessageContent(config.agents);
546-
const serviceName = otlp?.serviceName ?? (config.serviceNamePrefix || 'loongsuite-pilot');
561+
const serviceName = userServiceName;
547562
const resourceAttributes = { ...(otlp?.resourceAttributes ?? {}), ...armsResourceAttributes };
548563

549564
return {
@@ -565,14 +580,15 @@ function cmsEntryToOtlpEndpoint(
565580
name: string,
566581
cms: { endpoint: string; licenseKey?: string; workspace?: string; project?: string },
567582
armsResourceAttributes: Record<string, string>,
583+
serviceName?: string,
568584
): OtlpEndpoint {
569585
const headers: Record<string, string> = {};
570586
const armsProject = cms.project || extractArmsProject(cms.endpoint);
571587
if (cms.licenseKey) headers['x-arms-license-key'] = cms.licenseKey;
572588
if (armsProject) headers['x-arms-project'] = armsProject;
573589
if (cms.workspace) headers['x-cms-workspace'] = cms.workspace;
574590
armsResourceAttributes['acs.arms.service.feature'] = 'genai_app';
575-
return { name, endpoint: cms.endpoint, headers };
591+
return { name, endpoint: cms.endpoint, headers, serviceName };
576592
}
577593

578594
/** Stable serialization of headers (sorted keys) for dedup keying. */
@@ -585,17 +601,18 @@ function stableHeaderKey(headers?: Record<string, string>): string {
585601
}
586602

587603
/**
588-
* Dedup by normalized URL + full headers. Because the CMS shorthand encodes
589-
* license-key / project / workspace as headers, this subsumes those fields and
590-
* also distinguishes generic OTLP backends that share a URL but differ in auth
604+
* Dedup by normalized URL + full headers + serviceName. Because the CMS shorthand
605+
* encodes license-key / project / workspace as headers, this subsumes those fields
606+
* and also distinguishes generic OTLP backends that share a URL but differ in auth
591607
* headers — so a managed backend is never silently folded into a user endpoint.
592-
* First occurrence wins.
608+
* serviceName is included so the same URL under two service names is kept as two
609+
* distinct backends. First occurrence wins.
593610
*/
594611
function dedupOtlpEndpoints(endpoints: OtlpEndpoint[]): OtlpEndpoint[] {
595612
const seen = new Set<string>();
596613
const result: OtlpEndpoint[] = [];
597614
for (const ep of endpoints) {
598-
const key = `${normalizeEndpointUrl(ep.endpoint)}|${stableHeaderKey(ep.headers)}`;
615+
const key = `${normalizeEndpointUrl(ep.endpoint)}|${stableHeaderKey(ep.headers)}|${ep.serviceName ?? ''}`;
599616
if (seen.has(key)) continue;
600617
seen.add(key);
601618
result.push(ep);
@@ -696,9 +713,16 @@ function buildSlsConfig(file: ConfigFile | null, serviceNamePrefix: string, inne
696713
}
697714

698715
if (innerDataConfig?.sls && Array.isArray(innerDataConfig.sls)) {
716+
// Managed endpoints get their own __service_name__; only tag them when the
717+
// inner prefix differs, so the default case stays byte-identical to before.
718+
const innerPrefix = innerDataConfig.serviceNamePrefix;
719+
const innerServiceName = innerPrefix && innerPrefix !== serviceNamePrefix ? innerPrefix : undefined;
699720
const innerEndpoints = innerDataConfig.sls
700721
.filter(ep => ep.endpoint && ep.logstore)
701-
.map((ep, i) => parseSlsEndpointEntry(ep, i));
722+
.map((ep, i) => {
723+
const parsed = parseSlsEndpointEntry(ep, i);
724+
return innerServiceName ? { ...parsed, serviceName: innerServiceName } : parsed;
725+
});
702726
endpoints = [...endpoints, ...innerEndpoints];
703727
}
704728

@@ -788,7 +812,7 @@ function dedupSlsEndpoints(endpoints: SlsEndpoint[]): SlsEndpoint[] {
788812
const seen = new Set<string>();
789813
const result: SlsEndpoint[] = [];
790814
for (const ep of endpoints) {
791-
const key = `${normalizeEndpointUrl(ep.endpoint)}|${ep.project}|${ep.logstore}`;
815+
const key = `${normalizeEndpointUrl(ep.endpoint)}|${ep.project}|${ep.logstore}|${ep.serviceName ?? ''}`;
792816
if (seen.has(key)) continue;
793817
seen.add(key);
794818
result.push(ep);

src/flushers/otlp-trace-flusher.ts

Lines changed: 53 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ interface ResolvedOtlpEndpoint {
7070
url: string;
7171
headers: Record<string, string>;
7272
compression: CompressionAlgorithm;
73+
serviceName: string;
7374
}
7475

7576
interface AgentExportState {
@@ -167,6 +168,7 @@ export class OtlpTraceFlusher extends BaseFlusher {
167168
url: resolveEndpointUrl(ep.endpoint),
168169
headers: ep.headers ?? {},
169170
compression: ep.compression === 'none' ? CompressionAlgorithm.NONE : CompressionAlgorithm.GZIP,
171+
serviceName: ep.serviceName || cfg.serviceName,
170172
}));
171173
const dataDir = cfg.dataDir ?? os.homedir() + '/.loongsuite-pilot';
172174
this.pilotVersion = readInstalledVersion(dataDir);
@@ -296,11 +298,17 @@ export class OtlpTraceFlusher extends BaseFlusher {
296298
// --- Test seam ---
297299

298300
async exportSpansForAgent(agentType: string, spans: ReadableSpan[]): Promise<void> {
299-
const exportState = this.getOrCreateExportState(agentType);
300301
if (this.cfg.debug) {
301302
await this.writeDebugLog(agentType, spans);
302303
}
303-
await this.exportInBatches(exportState, agentType, spans);
304+
// Fan out to every backend; each endpoint belongs to exactly one serviceName
305+
// group, so the spans reach each backend once.
306+
const serviceNames = [...new Set(this.endpoints.map((e) => e.serviceName))];
307+
await Promise.all(
308+
serviceNames.map((serviceName) =>
309+
this.exportInBatches(this.getOrCreateExportState(agentType, serviceName), agentType, spans),
310+
),
311+
);
304312
}
305313

306314
// --- Internal ---
@@ -374,25 +382,35 @@ export class OtlpTraceFlusher extends BaseFlusher {
374382
): Promise<void> {
375383
if (records.length === 0) return;
376384
const projectedResourceAttributes = this.collectResourceAttributes(records);
377-
const convertKey = this.buildConvertStateKey(agentType, projectedResourceAttributes);
378-
const prev = this.convertLocks.get(convertKey) ?? Promise.resolve();
379-
const current = prev.then(() => this.doConvertAndExport(
380-
agentType,
381-
records,
382-
projectedResourceAttributes,
383-
convertKey,
384-
));
385-
this.convertLocks.set(convertKey, current.catch(() => {}));
386-
await current;
385+
// Convert once per distinct service.name (backends may split into user/inner
386+
// service names). Each service name owns an independent convert state, so the
387+
// common single-name case still converts exactly once.
388+
const serviceNames = [...new Set(this.endpoints.map((e) => e.serviceName))];
389+
await Promise.all(
390+
serviceNames.map((serviceName) => {
391+
const convertKey = this.buildConvertStateKey(agentType, serviceName, projectedResourceAttributes);
392+
const prev = this.convertLocks.get(convertKey) ?? Promise.resolve();
393+
const current = prev.then(() => this.doConvertAndExport(
394+
agentType,
395+
serviceName,
396+
records,
397+
projectedResourceAttributes,
398+
convertKey,
399+
));
400+
this.convertLocks.set(convertKey, current.catch(() => {}));
401+
return current;
402+
}),
403+
);
387404
}
388405

389406
private async doConvertAndExport(
390407
agentType: string,
408+
serviceName: string,
391409
records: AgentActivityEntry[],
392410
projectedResourceAttributes: Record<string, ResourceProjectionValue>,
393411
convertKey: string,
394412
): Promise<void> {
395-
const convertState = this.getOrCreateConvertState(agentType, projectedResourceAttributes, convertKey);
413+
const convertState = this.getOrCreateConvertState(agentType, serviceName, projectedResourceAttributes, convertKey);
396414
const { handler, provider, inMem } = convertState;
397415
convertState.active += 1;
398416

@@ -434,7 +452,7 @@ export class OtlpTraceFlusher extends BaseFlusher {
434452

435453
if (spans.length === 0) return;
436454

437-
const exportState = this.getOrCreateExportState(agentType);
455+
const exportState = this.getOrCreateExportState(agentType, serviceName);
438456

439457
if (this.cfg.debug) {
440458
await this.writeDebugLog(agentType, spans);
@@ -520,8 +538,9 @@ export class OtlpTraceFlusher extends BaseFlusher {
520538

521539
private getOrCreateConvertState(
522540
agentType: string,
541+
serviceName: string,
523542
projectedResourceAttributes: Record<string, ResourceProjectionValue> = {},
524-
key = this.buildConvertStateKey(agentType, projectedResourceAttributes),
543+
key = this.buildConvertStateKey(agentType, serviceName, projectedResourceAttributes),
525544
): AgentConvertState {
526545
let state = this.agentConvertStates.get(key);
527546
if (state) {
@@ -530,7 +549,7 @@ export class OtlpTraceFlusher extends BaseFlusher {
530549
return state;
531550
}
532551

533-
const resource = this.buildResource(agentType, projectedResourceAttributes);
552+
const resource = this.buildResource(agentType, serviceName, projectedResourceAttributes);
534553
const inMem = new InMemorySpanExporter();
535554
const provider = new BasicTracerProvider({
536555
resource,
@@ -564,9 +583,10 @@ export class OtlpTraceFlusher extends BaseFlusher {
564583

565584
private buildConvertStateKey(
566585
agentType: string,
586+
serviceName: string,
567587
projectedResourceAttributes: Record<string, ResourceProjectionValue>,
568588
): string {
569-
return `${agentType}|${this.stableJson(projectedResourceAttributes)}`;
589+
return `${agentType}|${serviceName}|${this.stableJson(projectedResourceAttributes)}`;
570590
}
571591

572592
private stableJson(value: Record<string, ResourceProjectionValue>): string {
@@ -635,27 +655,31 @@ export class OtlpTraceFlusher extends BaseFlusher {
635655
return undefined;
636656
}
637657

638-
private getOrCreateExportState(agentType: string): AgentExportState {
639-
let state = this.agentExportStates.get(agentType);
658+
private getOrCreateExportState(agentType: string, serviceName: string): AgentExportState {
659+
const key = `${agentType}|${serviceName}`;
660+
let state = this.agentExportStates.get(key);
640661
if (state) return state;
641662

642-
const exporters = this.endpoints.map((ep) => ({
643-
name: ep.name,
644-
exporter: this.exporterFactory({
645-
url: ep.url,
646-
headers: ep.headers,
647-
compression: ep.compression,
663+
const exporters = this.endpoints
664+
.filter((ep) => ep.serviceName === serviceName)
665+
.map((ep) => ({
648666
name: ep.name,
649-
}),
650-
}));
667+
exporter: this.exporterFactory({
668+
url: ep.url,
669+
headers: ep.headers,
670+
compression: ep.compression,
671+
name: ep.name,
672+
}),
673+
}));
651674

652675
state = { exporters };
653-
this.agentExportStates.set(agentType, state);
676+
this.agentExportStates.set(key, state);
654677
return state;
655678
}
656679

657680
private buildResource(
658681
agentType: string,
682+
serviceName: string,
659683
projectedResourceAttributes: Record<string, ResourceProjectionValue> = {},
660684
): Resource {
661685
const userAttrs: Record<string, string> = {};
@@ -686,7 +710,7 @@ export class OtlpTraceFlusher extends BaseFlusher {
686710
}
687711

688712
return new Resource({
689-
'service.name': `${this.cfg.serviceName}-${agentType}`,
713+
'service.name': `${serviceName}-${agentType}`,
690714
'service.version': this.pilotVersion,
691715
'service.instance.id': this.instanceId,
692716
'service.namespace': 'loongsuite-pilot',

0 commit comments

Comments
 (0)