Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion samples/src/langchainInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ async function main(): Promise<void> {
instrumentationOptions: {
langchain: {
enabled: true,
isContentRecordingEnabled: true,
},
},
});
Expand Down
1 change: 0 additions & 1 deletion samples/src/openaiInstrumentation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ async function main(): Promise<void> {
instrumentationOptions: {
openaiAgents: {
enabled: true,
isContentRecordingEnabled: true,
},
},
});
Expand Down
2 changes: 1 addition & 1 deletion src/a365/configuration/A365Configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export const A365_ENV_VARS = {
LOG_LEVEL: "A365_OBSERVABILITY_LOG_LEVEL",
} as const;

const DEFAULT_AUTH_SCOPE = "https://api.powerplatform.com/.default";
const DEFAULT_AUTH_SCOPE = "api://9b975845-388f-4429-889e-eab1ef63949c/.default";

const VALID_CLUSTER_CATEGORIES: ReadonlySet<string> = new Set([
"local",
Expand Down
116 changes: 85 additions & 31 deletions src/a365/exporter/Agent365Exporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import {
statusName,
resolveAgent365Endpoint,
truncateSpan,
estimateSpanBytes,
chunkBySize,
} from "./utils.js";
import { getA365Logger } from "../logging.js";

Expand Down Expand Up @@ -69,6 +71,13 @@ interface OTLPStatus {
message?: string;
}

interface MappedSpan {
span: OTLPSpan;
scopeKey: string;
scopeName: string;
scopeVersion?: string;
}

/**
* Agent365 span exporter.
*
Expand Down Expand Up @@ -150,8 +159,20 @@ export class Agent365Exporter implements SpanExporter {
const start = Date.now();
const { tenantId, agentId } = parseIdentityKey(identityKey);

const payload = this.buildExportRequest(spans);
const body = JSON.stringify(payload);
// Map, truncate, and chunk spans by estimated byte size
const mappedSpans = this.mapAndTruncateSpans(spans);
const resourceAttrs = this.getResourceAttributes(spans);
const chunks = chunkBySize(
mappedSpans,
(ms) => estimateSpanBytes(ms.span),
this.options.maxPayloadBytes,
);

if (chunks.length > 1) {
this.logger.info(
`[Agent365Exporter] Split ${spans.length} spans into ${chunks.length} chunks for ${tenantId}/${agentId}`,
);
}

const servicePrefix = this.options.useS2SEndpoint ? "/observabilityService" : "/observability";
const endpointPath = `${servicePrefix}/tenants/${encodeURIComponent(tenantId)}/otlp/agents/${encodeURIComponent(agentId)}/traces`;
Expand Down Expand Up @@ -182,22 +203,38 @@ export class Agent365Exporter implements SpanExporter {
}
headers["authorization"] = `Bearer ${token}`;

const { ok, correlationId } = await this.postWithRetries(url, body, headers);
if (!ok) {
this.logExporterEvent(ExporterEventNames.EXPORT_GROUP, false, Date.now() - start, undefined, {
tenantId,
agentId,
correlationId,
});
throw new Error(`Failed to export spans for ${tenantId}/${agentId}`);
// Send each chunk (all-or-nothing: fail on first chunk failure)
let lastCorrelationId = "unknown";
for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[i];
const payload = this.buildEnvelope(chunk, resourceAttrs);
const body = JSON.stringify(payload);

this.logger.info(
`[Agent365Exporter] Sending chunk ${i + 1} of ${chunks.length} (${chunk.length} spans)`,
);

const { ok, correlationId } = await this.postWithRetries(url, body, headers);
lastCorrelationId = correlationId;

if (!ok) {
this.logExporterEvent(
ExporterEventNames.EXPORT_GROUP,
false,
Date.now() - start,
`chunk ${i + 1} of ${chunks.length} failed`,
{ tenantId, agentId, correlationId },
);
throw new Error(`Failed to export spans (chunk ${i + 1} of ${chunks.length})`);
}
}

this.logExporterEvent(
ExporterEventNames.EXPORT_GROUP,
true,
Date.now() - start,
"Spans exported successfully",
{ tenantId, agentId, correlationId },
`${chunks.length} chunk(s) exported successfully`,
{ tenantId, agentId, correlationId: lastCorrelationId },
);
}

Expand Down Expand Up @@ -265,34 +302,51 @@ export class Agent365Exporter implements SpanExporter {
return { ok: false, correlationId: lastCorrelationId };
}

private buildExportRequest(spans: ReadableSpan[]): OTLPExportRequest {
private mapAndTruncateSpans(spans: ReadableSpan[]): MappedSpan[] {
return spans.map((sp) => {
const scope = sp.instrumentationScope;
const scopeName = scope?.name ?? "unknown";
const scopeVersion = scope?.version ?? "";
return {
span: truncateSpan(this.mapSpan(sp)),
scopeKey: `${scopeName}:${scopeVersion}`,
scopeName,
scopeVersion: scopeVersion || undefined,
};
});
}

private getResourceAttributes(spans: ReadableSpan[]): Record<string, unknown> {
if (spans.length > 0 && spans[0].resource?.attributes) {
return { ...spans[0].resource.attributes };
}
return {};
}

private buildEnvelope(
mappedSpans: MappedSpan[],
resourceAttrs: Record<string, unknown>,
): OTLPExportRequest {
const scopeMap = new Map<string, OTLPSpan[]>();

for (const sp of spans) {
const scope = sp.instrumentationScope;
const scopeKey = `${scope?.name ?? "unknown"}:${scope?.version ?? ""}`;
let existing = scopeMap.get(scopeKey);
if (!existing) {
existing = [];
scopeMap.set(scopeKey, existing);
}
existing.push(truncateSpan(this.mapSpan(sp)));
for (const ms of mappedSpans) {
const existing = scopeMap.get(ms.scopeKey) || [];
existing.push(ms.span);
scopeMap.set(ms.scopeKey, existing);
}

const scopeSpans: ScopeSpan[] = [];
for (const [scopeKey, mappedSpans] of scopeMap) {
const [name, version] = scopeKey.split(":");
for (const [scopeKey, spans] of scopeMap) {
const representative = mappedSpans.find((ms) => ms.scopeKey === scopeKey)!;
scopeSpans.push({
scope: { name, version: version || undefined },
spans: mappedSpans,
scope: {
name: representative.scopeName,
version: representative.scopeVersion,
},
spans,
});
}

let resourceAttrs: Record<string, unknown> = {};
if (spans.length > 0 && spans[0].resource?.attributes) {
resourceAttrs = { ...spans[0].resource.attributes };
}

return {
resourceSpans: [
{
Expand Down
9 changes: 8 additions & 1 deletion src/a365/exporter/Agent365ExporterOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export interface Agent365ExporterOptions {

/** Maximum number of spans per export batch. @default 512 */
maxExportBatchSize?: number;

/** Maximum estimated payload size (bytes) per HTTP chunk. @default 900 * 1024 (900KB) */
maxPayloadBytes?: number;
}

/** Resolved options with defaults applied. */
Expand All @@ -60,17 +63,21 @@ export class ResolvedExporterOptions {
public readonly exporterTimeoutMilliseconds: number;
public readonly httpRequestTimeoutMilliseconds: number;
public readonly maxExportBatchSize: number;
public readonly maxPayloadBytes: number;

constructor(options?: Agent365ExporterOptions) {
this.clusterCategory = options?.clusterCategory ?? "prod";
this.tokenResolver = options?.tokenResolver;
this.useS2SEndpoint = options?.useS2SEndpoint ?? false;
this.domainOverride = options?.domainOverride;
this.authScopes = options?.authScopes ?? ["https://api.powerplatform.com/.default"];
this.authScopes = options?.authScopes ?? [
"api://9b975845-388f-4429-889e-eab1ef63949c/.default",
];
this.maxQueueSize = options?.maxQueueSize ?? 2048;
this.scheduledDelayMilliseconds = options?.scheduledDelayMilliseconds ?? 5000;
this.exporterTimeoutMilliseconds = options?.exporterTimeoutMilliseconds ?? 90000;
this.httpRequestTimeoutMilliseconds = options?.httpRequestTimeoutMilliseconds ?? 30000;
this.maxExportBatchSize = options?.maxExportBatchSize ?? 512;
this.maxPayloadBytes = options?.maxPayloadBytes ?? 900 * 1024;
}
}
Loading
Loading