@@ -8,6 +8,7 @@ import type {
88 MetricData ,
99 PushMetricExporter ,
1010 ResourceMetrics ,
11+ ScopeMetrics ,
1112} from "@opentelemetry/sdk-metrics" ;
1213import { Span , SpanProcessor } from "@opentelemetry/sdk-trace-base" ;
1314import { SemanticInternalAttributes } from "../semanticInternalAttributes.js" ;
@@ -130,29 +131,44 @@ export class TaskContextMetricExporter implements PushMetricExporter {
130131
131132 export ( metrics : ResourceMetrics , resultCallback : ( result : ExportResult ) => void ) : void {
132133 if ( ! taskContext . ctx ) {
133- // No active run — drop metrics (between-run noise)
134+ // No context at all — drop metrics
134135 resultCallback ( { code : ExportResultCode . SUCCESS } ) ;
135136 return ;
136137 }
137138
138139 const ctx = taskContext . ctx ;
139- const contextAttrs : Attributes = {
140- [ SemanticInternalAttributes . RUN_ID ] : ctx . run . id ,
141- [ SemanticInternalAttributes . TASK_SLUG ] : ctx . task . id ,
142- [ SemanticInternalAttributes . ATTEMPT_NUMBER ] : ctx . attempt . number ,
143- [ SemanticInternalAttributes . ENVIRONMENT_ID ] : ctx . environment . id ,
144- [ SemanticInternalAttributes . ORGANIZATION_ID ] : ctx . organization . id ,
145- [ SemanticInternalAttributes . PROJECT_ID ] : ctx . project . id ,
146- [ SemanticInternalAttributes . MACHINE_PRESET_NAME ] : ctx . machine ?. name ,
147- [ SemanticInternalAttributes . ENVIRONMENT_TYPE ] : ctx . environment . type ,
148- } ;
140+
141+ let contextAttrs : Attributes ;
142+
143+ if ( taskContext . isRunDisabled ) {
144+ // Between runs: keep environment/project/org/machine attrs, strip run-specific ones
145+ contextAttrs = {
146+ [ SemanticInternalAttributes . ENVIRONMENT_ID ] : ctx . environment . id ,
147+ [ SemanticInternalAttributes . ENVIRONMENT_TYPE ] : ctx . environment . type ,
148+ [ SemanticInternalAttributes . ORGANIZATION_ID ] : ctx . organization . id ,
149+ [ SemanticInternalAttributes . PROJECT_ID ] : ctx . project . id ,
150+ [ SemanticInternalAttributes . MACHINE_PRESET_NAME ] : ctx . machine ?. name ,
151+ } ;
152+ } else {
153+ // During a run: full context attrs
154+ contextAttrs = {
155+ [ SemanticInternalAttributes . RUN_ID ] : ctx . run . id ,
156+ [ SemanticInternalAttributes . TASK_SLUG ] : ctx . task . id ,
157+ [ SemanticInternalAttributes . ATTEMPT_NUMBER ] : ctx . attempt . number ,
158+ [ SemanticInternalAttributes . ENVIRONMENT_ID ] : ctx . environment . id ,
159+ [ SemanticInternalAttributes . ORGANIZATION_ID ] : ctx . organization . id ,
160+ [ SemanticInternalAttributes . PROJECT_ID ] : ctx . project . id ,
161+ [ SemanticInternalAttributes . MACHINE_PRESET_NAME ] : ctx . machine ?. name ,
162+ [ SemanticInternalAttributes . ENVIRONMENT_TYPE ] : ctx . environment . type ,
163+ } ;
164+ }
149165
150166 if ( taskContext . worker ) {
151167 contextAttrs [ SemanticInternalAttributes . WORKER_ID ] = taskContext . worker . id ;
152168 contextAttrs [ SemanticInternalAttributes . WORKER_VERSION ] = taskContext . worker . version ;
153169 }
154170
155- if ( ctx . run . tags ?. length ) {
171+ if ( ! taskContext . isRunDisabled && ctx . run . tags ?. length ) {
156172 contextAttrs [ SemanticInternalAttributes . RUN_TAGS ] = ctx . run . tags ;
157173 }
158174
@@ -184,3 +200,108 @@ export class TaskContextMetricExporter implements PushMetricExporter {
184200 return this . _innerExporter . shutdown ( ) ;
185201 }
186202}
203+
204+ export class BufferingMetricExporter implements PushMetricExporter {
205+ selectAggregationTemporality ?: ( instrumentType : InstrumentType ) => AggregationTemporality ;
206+ selectAggregation ?: ( instrumentType : InstrumentType ) => AggregationOption ;
207+
208+ private _buffer : ResourceMetrics [ ] = [ ] ;
209+ private _lastFlushTime = Date . now ( ) ;
210+
211+ constructor (
212+ private _innerExporter : PushMetricExporter ,
213+ private _flushIntervalMs : number
214+ ) {
215+ if ( _innerExporter . selectAggregationTemporality ) {
216+ this . selectAggregationTemporality =
217+ _innerExporter . selectAggregationTemporality . bind ( _innerExporter ) ;
218+ }
219+ if ( _innerExporter . selectAggregation ) {
220+ this . selectAggregation = _innerExporter . selectAggregation . bind ( _innerExporter ) ;
221+ }
222+ }
223+
224+ export ( metrics : ResourceMetrics , resultCallback : ( result : ExportResult ) => void ) : void {
225+ this . _buffer . push ( metrics ) ;
226+
227+ const now = Date . now ( ) ;
228+ if ( now - this . _lastFlushTime >= this . _flushIntervalMs ) {
229+ this . _lastFlushTime = now ;
230+ const merged = this . _mergeBuffer ( ) ;
231+ this . _innerExporter . export ( merged , resultCallback ) ;
232+ } else {
233+ resultCallback ( { code : ExportResultCode . SUCCESS } ) ;
234+ }
235+ }
236+
237+ forceFlush ( ) : Promise < void > {
238+ if ( this . _buffer . length > 0 ) {
239+ this . _lastFlushTime = Date . now ( ) ;
240+ const merged = this . _mergeBuffer ( ) ;
241+ return new Promise < void > ( ( resolve , reject ) => {
242+ this . _innerExporter . export ( merged , ( result ) => {
243+ if ( result . code === ExportResultCode . SUCCESS ) {
244+ resolve ( ) ;
245+ } else {
246+ reject ( result . error ?? new Error ( "Export failed" ) ) ;
247+ }
248+ } ) ;
249+ } ) . then ( ( ) => this . _innerExporter . forceFlush ( ) ) ;
250+ }
251+ return this . _innerExporter . forceFlush ( ) ;
252+ }
253+
254+ shutdown ( ) : Promise < void > {
255+ return this . forceFlush ( ) . then ( ( ) => this . _innerExporter . shutdown ( ) ) ;
256+ }
257+
258+ private _mergeBuffer ( ) : ResourceMetrics {
259+ const batch = this . _buffer ;
260+ this . _buffer = [ ] ;
261+
262+ if ( batch . length === 1 ) {
263+ return batch [ 0 ] ! ;
264+ }
265+
266+ const base = batch [ 0 ] ! ;
267+
268+ // Merge all scopeMetrics by scope name, then metrics by descriptor name
269+ const scopeMap = new Map < string , { scope : ScopeMetrics [ "scope" ] ; metricsMap : Map < string , MetricData > } > ( ) ;
270+
271+ for ( const rm of batch ) {
272+ for ( const sm of rm . scopeMetrics ) {
273+ const scopeKey = sm . scope . name ;
274+ let scopeEntry = scopeMap . get ( scopeKey ) ;
275+ if ( ! scopeEntry ) {
276+ scopeEntry = { scope : sm . scope , metricsMap : new Map ( ) } ;
277+ scopeMap . set ( scopeKey , scopeEntry ) ;
278+ }
279+
280+ for ( const metric of sm . metrics ) {
281+ const metricKey = metric . descriptor . name ;
282+ const existing = scopeEntry . metricsMap . get ( metricKey ) ;
283+ if ( existing ) {
284+ // Append data points from this collection to the existing metric
285+ scopeEntry . metricsMap . set ( metricKey , {
286+ ...existing ,
287+ dataPoints : [ ...existing . dataPoints , ...metric . dataPoints ] ,
288+ } as MetricData ) ;
289+ } else {
290+ scopeEntry . metricsMap . set ( metricKey , {
291+ ...metric ,
292+ dataPoints : [ ...metric . dataPoints ] ,
293+ } as MetricData ) ;
294+ }
295+ }
296+ }
297+ }
298+
299+ return {
300+ resource : base . resource ,
301+ scopeMetrics : Array . from ( scopeMap . values ( ) ) . map ( ( { scope, metricsMap } ) => ( {
302+ scope,
303+ metrics : Array . from ( metricsMap . values ( ) ) ,
304+ } ) ) ,
305+ } ;
306+ }
307+ }
0 commit comments