@@ -111,6 +111,17 @@ pub struct Processor {
111111 /// on `platform.report`. This flag ensures whichever event arrives first wins and the other is skipped,
112112 /// preventing double counting.
113113 init_duration_metric_emitted : bool ,
114+ /// Timestamp of the invocation metric for the sandbox's first (cold start) invocation in
115+ /// On-Demand mode, held back from `enhanced_metrics` until `platform.initStart` is processed.
116+ ///
117+ /// The Extensions API's `/next` poll (which drives `on_invoke_event`) has no artificial
118+ /// delay, while `platform.initStart` is delivered through the buffered Telemetry API. In
119+ /// On-Demand mode the Invoke event consistently reaches the processor first, so emitting the
120+ /// invocation metric immediately would race `set_durable_function_tag` and miss the
121+ /// `durable_function:true` tag on invocation #1. Holding the metric here until
122+ /// `on_platform_init_start` (or the next invocation, or shutdown) flushes it ensures the tag
123+ /// is applied before the metric is counted.
124+ pending_first_invocation_metric : Option < i64 > ,
114125}
115126
116127impl Processor {
@@ -154,12 +165,24 @@ impl Processor {
154165 durable_context_tx,
155166 restore_time : None ,
156167 init_duration_metric_emitted : false ,
168+ pending_first_invocation_metric : None ,
157169 }
158170 }
159171
160172 /// Given a `request_id`, creates the context and adds the enhanced metric offsets to the context buffer.
161173 ///
162174 pub fn on_invoke_event ( & mut self , request_id : String ) {
175+ // Flush any invocation metric held back from a previous cold start. This is a fallback
176+ // for the case where `platform.initStart` never arrived (or arrived very late) before
177+ // this next invocation; see `pending_first_invocation_metric` for why it's held back.
178+ self . flush_pending_first_invocation_metric ( ) ;
179+
180+ // In On-Demand mode, the very first invocation of a cold sandbox races
181+ // `platform.initStart` (see `pending_first_invocation_metric`); hold its invocation
182+ // metric back instead of emitting it inline below.
183+ let is_on_demand_cold_start =
184+ !self . aws_config . is_managed_instance_mode ( ) && self . context_buffer . is_empty ( ) ;
185+
163186 // In Managed Instance mode, if awaiting the first invocation after init, find and update the empty context created on init start
164187 if self . aws_config . is_managed_instance_mode ( ) && self . awaiting_first_invocation {
165188 if self
@@ -224,8 +247,13 @@ impl Processor {
224247 . add_enhanced_metric_data ( & request_id, enhanced_metric_offsets) ;
225248 }
226249
227- // Increment the invocation metric
228- self . enhanced_metrics . increment_invocation_metric ( timestamp) ;
250+ // Increment the invocation metric, unless it's an On-Demand cold start, in which case
251+ // it's held back until `platform.initStart` (or a fallback) flushes it.
252+ if is_on_demand_cold_start {
253+ self . pending_first_invocation_metric = Some ( timestamp) ;
254+ } else {
255+ self . enhanced_metrics . increment_invocation_metric ( timestamp) ;
256+ }
229257 self . enhanced_metrics . set_invoked_received ( ) ;
230258
231259 // Both modes: check for buffered UniversalInstrumentationStart by request_id first.
@@ -257,6 +285,14 @@ impl Processor {
257285 }
258286 }
259287
288+ /// Emits the invocation metric held back by `on_invoke_event` for an On-Demand cold start,
289+ /// if one is pending. See `pending_first_invocation_metric` for why it's held back.
290+ fn flush_pending_first_invocation_metric ( & mut self ) {
291+ if let Some ( timestamp) = self . pending_first_invocation_metric . take ( ) {
292+ self . enhanced_metrics . increment_invocation_metric ( timestamp) ;
293+ }
294+ }
295+
260296 /// On the first invocation, determine if it's a cold start or proactive init.
261297 ///
262298 /// For every other invocation, it will always be warm start.
@@ -339,6 +375,10 @@ impl Processor {
339375 {
340376 self . enhanced_metrics . set_durable_function_tag ( ) ;
341377 }
378+ // Flush the held-back invocation metric now that the durable_function tag (if any) has
379+ // been set, so invocation #1 is counted with the correct tag.
380+ self . flush_pending_first_invocation_metric ( ) ;
381+
342382 let start_time: i64 = SystemTime :: from ( time)
343383 . duration_since ( UNIX_EPOCH )
344384 . expect ( "time went backwards" )
@@ -990,6 +1030,10 @@ impl Processor {
9901030 }
9911031
9921032 pub fn on_shutdown_event ( & mut self ) {
1033+ // Don't drop the invocation metric if the sandbox shuts down before
1034+ // `platform.initStart` (or a second invocation) had a chance to flush it.
1035+ self . flush_pending_first_invocation_metric ( ) ;
1036+
9931037 let now = std:: time:: SystemTime :: now ( )
9941038 . duration_since ( std:: time:: UNIX_EPOCH )
9951039 . expect ( "unable to poll clock, unrecoverable" )
@@ -2095,6 +2139,78 @@ mod tests {
20952139 ) ;
20962140 }
20972141
2142+ #[ tokio:: test]
2143+ async fn test_on_invoke_event_cold_start_holds_invocation_metric_until_init_start ( ) {
2144+ let mut processor = setup ( ) ;
2145+
2146+ // Simulate the On-Demand race: the Invoke event reaches the processor before
2147+ // `platform.initStart`. The invocation metric must not be emitted yet.
2148+ processor. on_invoke_event ( "req-1" . to_string ( ) ) ;
2149+ assert ! (
2150+ processor. pending_first_invocation_metric. is_some( ) ,
2151+ "Expected the cold start invocation metric to be held back"
2152+ ) ;
2153+
2154+ let now: i64 = std:: time:: UNIX_EPOCH
2155+ . elapsed ( )
2156+ . expect ( "unable to poll clock, unrecoverable" )
2157+ . as_secs ( )
2158+ . try_into ( )
2159+ . unwrap_or_default ( ) ;
2160+
2161+ // `platform.initStart` arrives afterwards and reports a durable runtime.
2162+ processor. on_platform_init_start (
2163+ Utc :: now ( ) ,
2164+ Some ( "python:3.14.DurableFunction.v6" . to_string ( ) ) ,
2165+ ) ;
2166+
2167+ assert ! (
2168+ processor. pending_first_invocation_metric. is_none( ) ,
2169+ "Expected the held-back invocation metric to be flushed by on_platform_init_start"
2170+ ) ;
2171+
2172+ let runtime = processor
2173+ . runtime
2174+ . clone ( )
2175+ . expect ( "runtime should have been resolved by on_invoke_event" ) ;
2176+ let ts = ( now / 10 ) * 10 ;
2177+ let expected_tags = dogstatsd:: metric:: SortedTags :: parse ( & format ! (
2178+ "cold_start:true,durable_function:true,runtime:{runtime}"
2179+ ) )
2180+ . ok ( ) ;
2181+ let entry = processor
2182+ . enhanced_metrics
2183+ . aggr_handle
2184+ . get_entry_by_id (
2185+ crate :: metrics:: enhanced:: constants:: INVOCATIONS_METRIC . into ( ) ,
2186+ expected_tags,
2187+ ts,
2188+ )
2189+ . await
2190+ . unwrap ( ) ;
2191+ assert ! (
2192+ entry. is_some( ) ,
2193+ "Expected the flushed invocation metric to carry the durable_function:true tag"
2194+ ) ;
2195+ }
2196+
2197+ #[ tokio:: test]
2198+ async fn test_on_invoke_event_flushes_pending_metric_if_init_start_never_arrives ( ) {
2199+ let mut processor = setup ( ) ;
2200+
2201+ // First invocation of a cold sandbox: metric held back.
2202+ processor. on_invoke_event ( "req-1" . to_string ( ) ) ;
2203+ assert ! ( processor. pending_first_invocation_metric. is_some( ) ) ;
2204+
2205+ // A second invocation arrives without platform.initStart ever showing up. The held-back
2206+ // metric from req-1 must still be flushed (as a fallback), not lost.
2207+ processor. on_invoke_event ( "req-2" . to_string ( ) ) ;
2208+ assert ! (
2209+ processor. pending_first_invocation_metric. is_none( ) ,
2210+ "Expected the pending metric to be flushed as a fallback on the next invocation"
2211+ ) ;
2212+ }
2213+
20982214 #[ tokio:: test]
20992215 async fn test_init_duration_emitted_from_init_report ( ) {
21002216 let mut processor = setup ( ) ;
0 commit comments