Skip to content

Commit f51d9ff

Browse files
archandattaclaude
andcommitted
feat: add GET /telemetry/events to read archived telemetry from S2
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 1b2384e commit f51d9ff

9 files changed

Lines changed: 929 additions & 348 deletions

File tree

server/cmd/api/api/api.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,17 @@ type ApiService struct {
9292
monitorMu sync.Mutex
9393
lifecycleCtx context.Context
9494
lifecycleCancel context.CancelFunc
95+
96+
// Durable S2 telemetry storage. All three must be set for reads to hit S2;
97+
// they mirror the values that gate the S2 storage writer.
98+
s2Basin string
99+
s2AccessToken string
100+
s2Stream string
101+
}
102+
103+
// s2Enabled reports whether durable S2 telemetry storage is configured.
104+
func (s *ApiService) s2Enabled() bool {
105+
return s.s2Basin != "" && s.s2AccessToken != "" && s.s2Stream != ""
95106
}
96107

97108
var _ oapi.StrictServerInterface = (*ApiService)(nil)
@@ -105,6 +116,9 @@ func New(
105116
telemetrySession *telemetry.TelemetrySession,
106117
eventStream *events.EventStream,
107118
displayNum int,
119+
s2Basin string,
120+
s2AccessToken string,
121+
s2Stream string,
108122
) (*ApiService, error) {
109123
switch {
110124
case recordManager == nil:
@@ -140,6 +154,9 @@ func New(
140154
cdpMonitor: mon,
141155
lifecycleCtx: ctx,
142156
lifecycleCancel: cancel,
157+
s2Basin: s2Basin,
158+
s2AccessToken: s2AccessToken,
159+
s2Stream: s2Stream,
143160
}, nil
144161
}
145162

server/cmd/api/api/api_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,7 +318,7 @@ func newTelemetrySession(t *testing.T) (*telemetry.TelemetrySession, *events.Eve
318318
func newSvc(t *testing.T, mgr recorder.RecordManager) (*ApiService, error) {
319319
t.Helper()
320320
ts, es := newTelemetrySession(t)
321-
return New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0)
321+
return New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, "", "", "")
322322
}
323323

324324
func TestApiService_PatchChromiumFlags(t *testing.T) {

server/cmd/api/api/display_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ func testFFmpegFactory(t *testing.T, tempDir string) recorder.FFmpegRecorderFact
3636
func newTestServiceWithFactory(t *testing.T, mgr recorder.RecordManager, factory recorder.FFmpegRecorderFactory) *ApiService {
3737
t.Helper()
3838
ts, es := newTelemetrySession(t)
39-
svc, err := New(mgr, factory, newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0)
39+
svc, err := New(mgr, factory, newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, "", "", "")
4040
require.NoError(t, err)
4141
return svc
4242
}

server/cmd/api/api/events.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"time"
1313

1414
"github.com/kernel/kernel-images/server/lib/events"
15+
"github.com/kernel/kernel-images/server/lib/logger"
1516
oapi "github.com/kernel/kernel-images/server/lib/oapi"
1617
)
1718

@@ -123,6 +124,115 @@ func (s *ApiService) StreamTelemetryEvents(ctx context.Context, req oapi.StreamT
123124
return oapi.StreamTelemetryEvents200TexteventStreamResponse{Body: pr, Headers: headers}, nil
124125
}
125126

127+
// defaultReadWindow bounds a read that supplies no since/until.
128+
const defaultReadWindow = 5 * time.Minute
129+
130+
// maxReadLimit caps the number of envelopes a single read returns.
131+
const maxReadLimit = 1000
132+
133+
// ReadTelemetryEvents handles GET /telemetry/events.
134+
// Reads archived telemetry envelopes for the current session from durable S2
135+
// storage, applies category and limit filters, and returns them in ascending
136+
// sequence order. Returns an empty list when S2 storage is not configured.
137+
func (s *ApiService) ReadTelemetryEvents(ctx context.Context, req oapi.ReadTelemetryEventsRequestObject) (oapi.ReadTelemetryEventsResponseObject, error) {
138+
log := logger.FromContext(ctx)
139+
140+
if !s.s2Enabled() {
141+
return readTelemetryEventsOKResponse{}, nil
142+
}
143+
144+
startSeq := s.telemetrySession.SessionStartSeq()
145+
envs, err := events.Read(ctx, s.s2Basin, s.s2AccessToken, s.s2Stream, buildReadOptions(req.Params), log)
146+
if err != nil {
147+
log.Error("failed to read telemetry events from S2", "err", err)
148+
return oapi.ReadTelemetryEvents500JSONResponse{InternalErrorJSONResponse: oapi.InternalErrorJSONResponse{Message: "failed to read telemetry events"}}, nil
149+
}
150+
151+
envs = dropPriorSessions(envs, startSeq)
152+
envs = filterByCategory(envs, req.Params.Category)
153+
envs = capLimit(envs, req.Params.Limit)
154+
155+
return readTelemetryEventsOKResponse{envs: envs}, nil
156+
}
157+
158+
// buildReadOptions maps query params to a bounded read window. since/until are
159+
// the start/end of the window; the window defaults to the last defaultReadWindow.
160+
// limit is applied after category filtering, not pushed into the S2 read.
161+
func buildReadOptions(p oapi.ReadTelemetryEventsParams) events.ReadOptions {
162+
var opts events.ReadOptions
163+
if p.Since != nil {
164+
start := uint64(*p.Since)
165+
opts.Timestamp = &start
166+
} else {
167+
start := uint64(time.Now().Add(-defaultReadWindow).UnixMilli())
168+
opts.Timestamp = &start
169+
}
170+
if p.Until != nil {
171+
until := uint64(*p.Until)
172+
opts.Until = &until
173+
}
174+
return opts
175+
}
176+
177+
// dropPriorSessions removes envelopes from before the current session's start.
178+
// startSeq is 0 when no session has run, in which case nothing is dropped.
179+
func dropPriorSessions(envs []events.Envelope, startSeq uint64) []events.Envelope {
180+
if startSeq == 0 {
181+
return envs
182+
}
183+
out := make([]events.Envelope, 0, len(envs))
184+
for _, e := range envs {
185+
if e.Seq >= startSeq {
186+
out = append(out, e)
187+
}
188+
}
189+
return out
190+
}
191+
192+
func filterByCategory(envs []events.Envelope, cats *[]oapi.TelemetryEventCategory) []events.Envelope {
193+
if cats == nil || len(*cats) == 0 {
194+
return envs
195+
}
196+
want := make(map[oapi.TelemetryEventCategory]struct{}, len(*cats))
197+
for _, c := range *cats {
198+
want[c] = struct{}{}
199+
}
200+
out := make([]events.Envelope, 0, len(envs))
201+
for _, e := range envs {
202+
if _, ok := want[e.Event.Category]; ok {
203+
out = append(out, e)
204+
}
205+
}
206+
return out
207+
}
208+
209+
func capLimit(envs []events.Envelope, limit *int) []events.Envelope {
210+
n := maxReadLimit
211+
if limit != nil && *limit > 0 && *limit < n {
212+
n = *limit
213+
}
214+
if len(envs) > n {
215+
return envs[:n]
216+
}
217+
return envs
218+
}
219+
220+
// readTelemetryEventsOKResponse serializes events.Envelope directly so the
221+
// response shape matches the SSE stream frames and the publish endpoint.
222+
type readTelemetryEventsOKResponse struct{ envs []events.Envelope }
223+
224+
func (r readTelemetryEventsOKResponse) VisitReadTelemetryEventsResponse(w http.ResponseWriter) error {
225+
w.Header().Set("Content-Type", "application/json")
226+
w.WriteHeader(http.StatusOK)
227+
envs := r.envs
228+
if envs == nil {
229+
envs = []events.Envelope{}
230+
}
231+
return json.NewEncoder(w).Encode(struct {
232+
Events []events.Envelope `json:"events"`
233+
}{Events: envs})
234+
}
235+
126236
// publishTelemetryEventOKResponse serializes events.Envelope directly so the response
127237
// is identical in shape to the SSE stream frames.
128238
type publishTelemetryEventOKResponse struct{ env events.Envelope }

server/cmd/api/api/events_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"bufio"
55
"context"
66
"encoding/json"
7+
"net/http"
8+
"net/http/httptest"
79
"strings"
810
"testing"
911
"time"
@@ -155,3 +157,69 @@ func TestPublishDroppedWhenCategoryDisabled(t *testing.T) {
155157
require.NoError(t, err)
156158
assert.IsType(t, oapi.PublishTelemetryEvent204Response{}, resp, "events in disabled categories should return 204")
157159
}
160+
161+
func TestReadTelemetryEventsS2Disabled(t *testing.T) {
162+
t.Parallel()
163+
svc := newTestService(t, newMockRecordManager()) // s2 creds empty -> disabled
164+
165+
resp, err := svc.ReadTelemetryEvents(context.Background(), oapi.ReadTelemetryEventsRequestObject{})
166+
require.NoError(t, err)
167+
ok, isOK := resp.(readTelemetryEventsOKResponse)
168+
require.True(t, isOK, "expected 200 response when S2 is disabled")
169+
170+
rec := httptest.NewRecorder()
171+
require.NoError(t, ok.VisitReadTelemetryEventsResponse(rec))
172+
assert.Equal(t, http.StatusOK, rec.Code)
173+
// Empty result must serialize as [] not null, or the Python SDK chokes.
174+
assert.JSONEq(t, `{"events":[]}`, rec.Body.String())
175+
}
176+
177+
func TestDropPriorSessions(t *testing.T) {
178+
t.Parallel()
179+
envs := []events.Envelope{{Seq: 1}, {Seq: 2}, {Seq: 3}}
180+
181+
got := dropPriorSessions(envs, 2)
182+
require.Len(t, got, 2)
183+
assert.Equal(t, uint64(2), got[0].Seq)
184+
185+
// startSeq 0 means no session ran; keep everything.
186+
assert.Len(t, dropPriorSessions(envs, 0), 3)
187+
}
188+
189+
func TestFilterByCategory(t *testing.T) {
190+
t.Parallel()
191+
mk := func(c oapi.TelemetryEventCategory) events.Envelope {
192+
return events.Envelope{Event: events.Event{Category: c}}
193+
}
194+
envs := []events.Envelope{mk(events.Console), mk(events.Network), mk(events.Console)}
195+
196+
assert.Len(t, filterByCategory(envs, nil), 3, "nil filter keeps everything")
197+
198+
cats := []oapi.TelemetryEventCategory{events.Console}
199+
assert.Len(t, filterByCategory(envs, &cats), 2)
200+
}
201+
202+
func TestCapLimit(t *testing.T) {
203+
t.Parallel()
204+
envs := make([]events.Envelope, 5)
205+
206+
assert.Len(t, capLimit(envs, nil), 5, "no limit returns all under the ceiling")
207+
208+
three := 3
209+
assert.Len(t, capLimit(envs, &three), 3)
210+
}
211+
212+
func TestBuildReadOptions(t *testing.T) {
213+
t.Parallel()
214+
// No params: defaults the start to roughly defaultReadWindow ago, no end bound.
215+
opts := buildReadOptions(oapi.ReadTelemetryEventsParams{})
216+
require.NotNil(t, opts.Timestamp)
217+
assert.Nil(t, opts.Until)
218+
219+
since, until := int64(1000), int64(2000)
220+
opts = buildReadOptions(oapi.ReadTelemetryEventsParams{Since: &since, Until: &until})
221+
require.NotNil(t, opts.Timestamp)
222+
require.NotNil(t, opts.Until)
223+
assert.Equal(t, uint64(1000), *opts.Timestamp)
224+
assert.Equal(t, uint64(2000), *opts.Until)
225+
}

server/cmd/api/api/telemetry_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ func (m *mockRecordManager) StopAll(_ context.Context) error
358358
func newTestService(t *testing.T, mgr recorder.RecordManager) *ApiService {
359359
t.Helper()
360360
ts, es := newTelemetrySession(t)
361-
svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0)
361+
svc, err := New(mgr, newMockFactory(), newTestUpstreamManager(), scaletozero.NewNoopController(), newMockNekoClient(t), ts, es, 0, "", "", "")
362362
require.NoError(t, err)
363363
svc.cdpMonitor = &stubCdpMonitor{}
364364
return svc

server/cmd/api/main.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,9 @@ func main() {
137137
telemetrySession,
138138
eventStream,
139139
config.DisplayNum,
140+
config.S2Basin,
141+
config.S2AccessToken,
142+
config.S2Stream,
140143
)
141144
if err != nil {
142145
slogger.Error("failed to create api service", "err", err)

0 commit comments

Comments
 (0)