11package telemetry
22
33import (
4- "bytes"
54 "context"
6- "encoding/json"
7- "fmt"
8- "net/http"
95 "os"
106 "runtime"
117 "sync"
128 "time"
139
1410 "github.com/google/uuid"
15- "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
16-
17- "github.com/localstack/lstk/internal/version"
1811)
1912
20- func userAgent () string {
21- return fmt .Sprintf ("localstack lstk/%s (%s; %s)" , version .Version (), runtime .GOOS , runtime .GOARCH )
22- }
13+ // pendingCap bounds in-memory events; on overflow the oldest is dropped.
14+ const pendingCap = 64
2315
2416type Client struct {
2517 enabled bool
2618 sessionID string
2719 machineID string
2820 authToken string
2921
30- httpClient * http. Client
31- endpoint string
22+ endpoint string
23+ flushFn func ( ctx context. Context , endpoint string , events [] eventBody )
3224
33- events chan eventBody
34- done chan struct {}
25+ mu sync.Mutex
26+ pending []eventBody
27+ traceCtx context.Context // last Emit ctx; carries the command span for trace propagation
3528 closeOnce sync.Once
3629 machineIDOnce sync.Once
3730}
@@ -46,37 +39,23 @@ func New(endpoint string, disabled bool) *Client {
4639 if disabled {
4740 return & Client {enabled : false }
4841 }
49- c := & Client {
42+ return & Client {
5043 enabled : true ,
5144 sessionID : uuid .NewString (),
52- // http.Client has no default timeout (zero means none). Without one, a
53- // slow or unreachable endpoint would block the worker goroutine.
54- httpClient : & http.Client {
55- Timeout : 3 * time .Second ,
56- Transport : otelhttp .NewTransport (
57- http .DefaultTransport ,
58- otelhttp .WithSpanNameFormatter (func (_ string , r * http.Request ) string {
59- return "telemetry " + r .Method + " " + r .URL .Path
60- }),
61- ),
62- },
63- endpoint : endpoint ,
64- events : make (chan eventBody , 64 ),
65- done : make (chan struct {}),
45+ endpoint : endpoint ,
46+ flushFn : spawnDetachedFlusher ,
47+ pending : make ([]eventBody , 0 , pendingCap ),
6648 }
67- go c .worker ()
68- return c
6949}
7050
7151type requestBody struct {
7252 Events []eventBody `json:"events"`
7353}
7454
7555type eventBody struct {
76- ctx context.Context // not serialized; carries context to the worker
77- Name string `json:"name"`
78- Metadata eventMetadata `json:"metadata"`
79- Payload any `json:"payload"`
56+ Name string `json:"name"`
57+ Metadata eventMetadata `json:"metadata"`
58+ Payload any `json:"payload"`
8059}
8160
8261type eventMetadata struct {
@@ -101,56 +80,41 @@ func (c *Client) Emit(ctx context.Context, name string, payload map[string]any)
10180 }
10281
10382 body := eventBody {
104- ctx : context .WithoutCancel (ctx ),
10583 Name : name ,
10684 Metadata : eventMetadata {
10785 ClientTime : time .Now ().UTC ().Format ("2006-01-02 15:04:05.000000" ),
10886 SessionID : c .sessionID ,
10987 },
11088 Payload : enriched ,
11189 }
112- select {
113- case c .events <- body :
114- default :
115- }
116- }
117-
118- func (c * Client ) worker () {
119- defer close (c .done )
120- for body := range c .events {
121- c .send (body )
122- }
123- }
12490
125- func (c * Client ) send (body eventBody ) {
126- data , err := json .Marshal (requestBody {Events : []eventBody {body }})
127- if err != nil {
128- return
129- }
130-
131- req , err := http .NewRequestWithContext (body .ctx , http .MethodPost , c .endpoint , bytes .NewReader (data ))
132- if err != nil {
133- return
134- }
135- req .Header .Set ("Content-Type" , "application/json" )
136- req .Header .Set ("User-Agent" , userAgent ())
137-
138- resp , err := c .httpClient .Do (req )
139- if err != nil {
140- return
91+ c .mu .Lock ()
92+ if len (c .pending ) >= pendingCap {
93+ c .pending = c .pending [1 :]
14194 }
142- _ = resp .Body .Close ()
95+ c .pending = append (c .pending , body )
96+ c .traceCtx = context .WithoutCancel (ctx )
97+ c .mu .Unlock ()
14398}
14499
145- // Close stops accepting new events, drains the event buffer, and blocks until
146- // all pending HTTP requests have completed. Call it before process exit to
147- // avoid dropping telemetry events.
100+ // Close hands pending events to a detached subprocess and returns immediately,
101+ // so analytics endpoint latency never delays command exit.
148102func (c * Client ) Close () {
149103 if ! c .enabled {
150104 return
151105 }
152106 c .closeOnce .Do (func () {
153- close (c .events )
154- <- c .done
107+ c .mu .Lock ()
108+ pending := c .pending
109+ traceCtx := c .traceCtx
110+ c .pending = nil
111+ c .mu .Unlock ()
112+ if len (pending ) == 0 {
113+ return
114+ }
115+ if traceCtx == nil {
116+ traceCtx = context .Background ()
117+ }
118+ c .flushFn (traceCtx , c .endpoint , pending )
155119 })
156120}
0 commit comments