@@ -146,9 +146,10 @@ type LLMObs struct {
146146 // lifecycle
147147 mu sync.Mutex
148148 running bool
149- wg sync.WaitGroup
150- stopCh chan struct {} // signal stop
151- flushNowCh chan struct {}
149+ sendWg sync.WaitGroup // tracks in-flight batchSend goroutines
150+ workerDone chan struct {} // closed when the worker loop exits
151+ stopCh chan struct {} // signal stop
152+ flushNowCh chan chan struct {}
152153 flushInterval time.Duration
153154}
154155
@@ -187,8 +188,9 @@ func newLLMObs(cfg *config.Config, tracer Tracer) (*LLMObs, error) {
187188 Tracer : tracer ,
188189 spanEventsCh : make (chan * transport.LLMObsSpanEvent ),
189190 evalMetricsCh : make (chan * transport.LLMObsMetric ),
191+ workerDone : make (chan struct {}),
190192 stopCh : make (chan struct {}),
191- flushNowCh : make (chan struct {}, 1 ),
193+ flushNowCh : make (chan chan struct {}, 1 ),
192194 flushInterval : defaultFlushInterval ,
193195 }, nil
194196}
@@ -245,6 +247,13 @@ func Flush() {
245247 }
246248}
247249
250+ // FlushSync forces a flush of all buffered LLMObs data and blocks until the flush completes.
251+ func FlushSync () {
252+ if activeLLMObs != nil {
253+ activeLLMObs .FlushSync ()
254+ }
255+ }
256+
248257// Run starts the worker loop that processes span events and metrics.
249258func (l * LLMObs ) Run () {
250259 l .mu .Lock ()
@@ -255,7 +264,8 @@ func (l *LLMObs) Run() {
255264 l .running = true
256265 l .mu .Unlock ()
257266
258- l .wg .Go (func () {
267+ go func () {
268+ defer close (l .workerDone )
259269 // this goroutine should be the only one writing to the internal buffers
260270
261271 ticker := time .NewTicker (l .flushInterval )
@@ -268,9 +278,7 @@ func (l *LLMObs) Run() {
268278 if l .bufSpanEventsSize + evSize > sizeLimitEVPEvent {
269279 log .Debug ("llmobs: span events buffer size limit reached, flushing before adding new event" )
270280 params := l .clearBuffersNonLocked ()
271- l .wg .Go (func () {
272- l .batchSend (params )
273- })
281+ l .sendWg .Go (func () { l .batchSend (params ) })
274282 }
275283 l .bufSpanEvents = append (l .bufSpanEvents , ev )
276284 l .bufSpanEventsSize += evSize
@@ -280,16 +288,22 @@ func (l *LLMObs) Run() {
280288
281289 case <- ticker .C :
282290 params := l .clearBuffersNonLocked ()
283- l .wg .Go (func () {
284- l .batchSend (params )
285- })
291+ l .sendWg .Go (func () { l .batchSend (params ) })
286292
287- case <- l .flushNowCh :
293+ case done := <- l .flushNowCh :
288294 log .Debug ("llmobs: on-demand flush signal" )
289295 params := l .clearBuffersNonLocked ()
290- l .wg .Go (func () {
296+ l .sendWg .Add (1 )
297+ go func () {
298+ defer func () {
299+ l .sendWg .Done ()
300+ if done != nil {
301+ l .sendWg .Wait ()
302+ close (done )
303+ }
304+ }()
291305 l .batchSend (params )
292- })
306+ }( )
293307
294308 case <- l .stopCh :
295309 log .Debug ("llmobs: stop signal" )
@@ -299,7 +313,7 @@ func (l *LLMObs) Run() {
299313 return
300314 }
301315 }
302- })
316+ }( )
303317}
304318
305319// clearBuffersNonLocked clears the internal buffers and returns the corresponding batchSendParams to send to the backend.
@@ -320,11 +334,24 @@ func (l *LLMObs) clearBuffersNonLocked() batchSendParams {
320334func (l * LLMObs ) Flush () {
321335 // non-blocking edge trigger so multiple calls coalesce
322336 select {
323- case l .flushNowCh <- struct {}{} :
337+ case l .flushNowCh <- nil :
324338 default :
325339 }
326340}
327341
342+ // FlushSync forces an immediate flush and blocks until the flush completes.
343+ func (l * LLMObs ) FlushSync () {
344+ done := make (chan struct {})
345+ select {
346+ case l .flushNowCh <- done :
347+ select {
348+ case <- done :
349+ case <- l .stopCh :
350+ }
351+ case <- l .stopCh :
352+ }
353+ }
354+
328355// Stop requests shutdown, drains what’s already in the channels, flushes, and waits.
329356func (l * LLMObs ) Stop () {
330357 l .mu .Lock ()
@@ -342,8 +369,10 @@ func (l *LLMObs) Stop() {
342369 close (l .stopCh )
343370 }
344371
345- // Wait for the main worker to exit (it will do a final flush)
346- l .wg .Wait ()
372+ // Wait for the worker loop to exit (it does a final synchronous flush),
373+ // then wait for any async batchSend goroutines still in flight.
374+ <- l .workerDone
375+ l .sendWg .Wait ()
347376}
348377
349378// drainChannels pulls everything currently buffered in the channels into our in-memory buffers.
0 commit comments