diff --git a/apps/checker/handlers/dns.go b/apps/checker/handlers/dns.go index 703a4c37..9f4df05d 100644 --- a/apps/checker/handlers/dns.go +++ b/apps/checker/handlers/dns.go @@ -38,6 +38,23 @@ type DNSResponse struct { Error uint8 `json:"error"` } +// dnsTinybirdEvent re-types Records as a JSON string so Tinybird stores it in +// the single `records` String column instead of auto-flattening the nested +// object into quarantined records_* columns. The outer Records shadows the +// embedded map in JSON output. +type dnsTinybirdEvent struct { + DNSResponse + Records string `json:"records"` +} + +func (d DNSResponse) tinybirdEvent() (dnsTinybirdEvent, error) { + j, err := json.Marshal(d.Records) + if err != nil { + return dnsTinybirdEvent{}, err + } + return dnsTinybirdEvent{DNSResponse: d, Records: string(j)}, nil +} + func (h Handler) DNSHandler(c *gin.Context) { ctx := c.Request.Context() const defaultRetry = 3 @@ -199,7 +216,9 @@ func (h Handler) DNSHandler(c *gin.Context) { data.RequestStatus = "success" } - if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + if tbEvent, err := data.tinybirdEvent(); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to marshal dns records") + } else if err := h.TbClient.SendEvent(ctx, tbEvent, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } @@ -207,11 +226,11 @@ func (h Handler) DNSHandler(c *gin.Context) { if f { t := event.(map[string]any) t["checker"] = map[string]string{ - "uri": req.URI, + "uri": req.URI, "workspace_id": req.WorkspaceID, - "monitor_id":req.MonitorID, - "trigger": trigger, - "type": "dns", + "monitor_id": req.MonitorID, + "trigger": trigger, + "type": "dns", } c.Set("event", t) } @@ -330,7 +349,9 @@ func (h Handler) DNSHandlerRegion(c *gin.Context) { data.Records = FormatDNSResult(result) if req.RequestId != 0 { - if err := h.TbClient.SendEvent(ctx, data, dataSourceName); err != nil { + if tbEvent, err := data.tinybirdEvent(); err != nil { + log.Ctx(ctx).Error().Err(err).Msg("failed to marshal dns records") + } else if err := h.TbClient.SendEvent(ctx, tbEvent, dataSourceName); err != nil { log.Ctx(ctx).Error().Err(err).Msg("failed to send event to tinybird") } } diff --git a/apps/checker/handlers/dns_internal_test.go b/apps/checker/handlers/dns_internal_test.go new file mode 100644 index 00000000..0d3b64c3 --- /dev/null +++ b/apps/checker/handlers/dns_internal_test.go @@ -0,0 +1,84 @@ +package handlers + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestTinybirdEventRecordsIsString(t *testing.T) { + records := map[string][]string{ + "A": {"1.2.3.4", "5.6.7.8"}, + "CNAME": {"example.com"}, + "MX": {}, + } + data := DNSResponse{URI: "example.com", Region: "ams", WorkspaceID: 42, Records: records} + + event, err := data.tinybirdEvent() + if err != nil { + t.Fatalf("tinybirdEvent() error = %v", err) + } + + b, err := json.Marshal(event) + if err != nil { + t.Fatalf("marshal event error = %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal payload error = %v", err) + } + + // records must be a JSON string, not an object — otherwise Tinybird + // auto-flattens it into quarantined records_* columns. + var recordsField string + if err := json.Unmarshal(raw["records"], &recordsField); err != nil { + t.Fatalf("records field is not a JSON string: %s", raw["records"]) + } + + var roundTrip map[string][]string + if err := json.Unmarshal([]byte(recordsField), &roundTrip); err != nil { + t.Fatalf("records string is not valid JSON: %v", err) + } + if !reflect.DeepEqual(roundTrip, records) { + t.Errorf("records round-trip = %v, want %v", roundTrip, records) + } + + // sibling fields survive the embed + if got := string(raw["uri"]); got != `"example.com"` { + t.Errorf("uri = %s, want %q", got, "example.com") + } + if got := string(raw["workspaceId"]); got != "42" { + t.Errorf("workspaceId = %s, want 42", got) + } +} + +func TestTinybirdEventNilRecords(t *testing.T) { + event, err := DNSResponse{URI: "example.com"}.tinybirdEvent() + if err != nil { + t.Fatalf("tinybirdEvent() error = %v", err) + } + if event.Records != "null" { + t.Errorf("nil records marshaled to %q, want %q", event.Records, "null") + } +} + +func TestDNSResponseHTTPShapeKeepsObject(t *testing.T) { + data := DNSResponse{Records: map[string][]string{"A": {"1.2.3.4"}}} + + b, err := json.Marshal(data) + if err != nil { + t.Fatalf("marshal error = %v", err) + } + + var raw map[string]json.RawMessage + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal error = %v", err) + } + + // the HTTP response (consumed by the test-DNS flow + assertions) must keep + // records as an object, not a string. + if got := raw["records"][0]; got != '{' { + t.Errorf("HTTP records is not a JSON object: %s", raw["records"]) + } +} diff --git a/apps/private-location/internal/server/ingest_dns.go b/apps/private-location/internal/server/ingest_dns.go index 46831cf7..05315610 100644 --- a/apps/private-location/internal/server/ingest_dns.go +++ b/apps/private-location/internal/server/ingest_dns.go @@ -2,6 +2,7 @@ package server import ( "context" + "encoding/json" "strconv" "connectrpc.com/connect" @@ -10,14 +11,16 @@ import ( ) type DNSResponse struct { - ID string `json:"id"` - Timing string `json:"timing"` - ErrorMessage string `json:"errorMessage"` - Region string `json:"region"` - Trigger string `json:"trigger"` - URI string `json:"uri"` - RequestStatus string `json:"requestStatus,omitempty"` - Records map[string][]string `json:"records"` + ID string `json:"id"` + Timing string `json:"timing"` + ErrorMessage string `json:"errorMessage"` + Region string `json:"region"` + Trigger string `json:"trigger"` + URI string `json:"uri"` + RequestStatus string `json:"requestStatus,omitempty"` + // JSON-encoded map so Tinybird stores it in the single `records` String + // column instead of auto-flattening into quarantined records_* columns. + Records string `json:"records"` RequestId int64 `json:"requestId,omitempty"` WorkspaceID int64 `json:"workspaceId"` @@ -63,6 +66,11 @@ func (h *privateLocationHandler) IngestDNS(ctx context.Context, req *connect.Req records[record.String()] = r } + recordsJSON, err := json.Marshal(records) + if err != nil { + return nil, connect.NewError(connect.CodeInternal, err) + } + data := DNSResponse{ ID: req.Msg.Id, WorkspaceID: int64(ic.Monitor.WorkspaceID), @@ -76,7 +84,7 @@ func (h *privateLocationHandler) IngestDNS(ctx context.Context, req *connect.Req Trigger: "cron", URI: req.Msg.Uri, RequestStatus: req.Msg.RequestStatus, - Records: records, + Records: string(recordsJSON), } h.sendEventAndUpdateLastSeen(ctx, data, tinybird.DatasourceDNS, ic.Region.ID)