-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add Amazon Data Firehose + S3 telemetry mirror #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
1bfb792
feat: add optional Firehose telemetry mirror
CoreyLeath-code ba3fbb9
test: cover Firehose mirror configuration and payloads
CoreyLeath-code ee00244
feat: provision Data Firehose delivery to encrypted S3
CoreyLeath-code b436c2d
chore: add AWS telemetry Terraform variables
CoreyLeath-code a8236cd
chore: expose Firehose and S3 Terraform outputs
CoreyLeath-code 6884db9
chore: replace misnamed Terraform file
CoreyLeath-code 2212add
feat: mirror accepted inference telemetry to Firehose
CoreyLeath-code 721485a
build: add AWS SDK v2 Firehose dependencies
CoreyLeath-code 567f45f
build: upgrade ingestion builder to Go 1.24
CoreyLeath-code e4e3d7c
docs: add Firehose environment configuration
CoreyLeath-code cd3dfe5
chore: expose optional Firehose settings in Compose
CoreyLeath-code 8045a84
docs: document Firehose to S3 telemetry architecture
CoreyLeath-code 74b6896
ci: validate Firehose producer and Terraform
CoreyLeath-code 88f75a4
docs: surface Firehose and S3 architecture in README
CoreyLeath-code 2dfc0b6
style: normalize Terraform formatting
CoreyLeath-code b46de07
ci: refresh ingestion Go module checksums
CoreyLeath-code ed05c6c
ci: surface Go module and Terraform formatting drift
CoreyLeath-code ddd72e6
build: refresh ingestion Go module checksums
github-actions[bot] 4fa8cff
chore: remove temporary Go sums refresh workflow
CoreyLeath-code c324f0f
fix: align Firehose S3 IAM and version retention
CoreyLeath-code 93215f3
docs: finalize Firehose and S3 validation boundaries
CoreyLeath-code File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| name: AWS Telemetry Validation | ||
|
|
||
| on: | ||
| pull_request: | ||
| branches: [main] | ||
| paths: | ||
| - "ingestion-service/**" | ||
| - "terraform/**" | ||
| - ".github/workflows/aws-telemetry.yml" | ||
| push: | ||
| branches: [main] | ||
| paths: | ||
| - "ingestion-service/**" | ||
| - "terraform/**" | ||
| - ".github/workflows/aws-telemetry.yml" | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| jobs: | ||
| firehose-producer: | ||
| name: Firehose producer unit tests | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
|
|
||
| defaults: | ||
| run: | ||
| working-directory: ingestion-service | ||
|
|
||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Set up Go | ||
| uses: actions/setup-go@v5 | ||
| with: | ||
| go-version: "1.24.x" | ||
| cache-dependency-path: ingestion-service/go.sum | ||
|
|
||
| - name: Verify module files are tidy | ||
| run: | | ||
| go mod tidy | ||
| git diff --exit-code -- go.mod go.sum | ||
|
|
||
| - name: Run ingestion tests | ||
| run: go test ./... | ||
|
|
||
| terraform: | ||
| name: Terraform fmt and validate | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
|
|
||
| defaults: | ||
| run: | ||
| working-directory: terraform | ||
|
|
||
| steps: | ||
| - name: Checkout repository | ||
| uses: actions/checkout@v4 | ||
|
|
||
| - name: Set up Terraform | ||
| uses: hashicorp/setup-terraform@v3 | ||
| with: | ||
| terraform_version: "1.13.3" | ||
|
|
||
| - name: Verify Terraform formatting | ||
| run: | | ||
| terraform fmt -recursive | ||
| git diff --exit-code -- . | ||
|
|
||
| - name: Initialize providers without backend | ||
| run: terraform init -backend=false -input=false | ||
|
|
||
| - name: Validate configuration | ||
| run: terraform validate |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| # Amazon Data Firehose + S3 telemetry path | ||
|
|
||
| SentinelAI can optionally mirror accepted inference telemetry from the Go ingestion service to Amazon Data Firehose. Firehose buffers the records and delivers GZIP-compressed newline-delimited JSON (NDJSON) objects to a private S3 telemetry bucket. | ||
|
|
||
| This AWS path is **optional**. Local Docker Compose keeps `FIREHOSE_ENABLED=false`, so PostgreSQL remains the default local persistence path and no AWS account is required for development. | ||
|
|
||
| ## Architecture | ||
|
|
||
| ```mermaid | ||
| flowchart LR | ||
| Client[Model / application] --> Gateway[NGINX ingestion gateway] | ||
| Gateway --> Go[Go ingestion replicas] | ||
| Go --> DB[(PostgreSQL / Snowflake path)] | ||
| Go -. bounded fail-open mirror .-> Queue[In-memory Firehose queue] | ||
| Queue --> Firehose[Amazon Data Firehose] | ||
| Firehose --> S3[(Amazon S3 telemetry lake)] | ||
| Firehose --> CW[CloudWatch delivery logs] | ||
| ``` | ||
|
|
||
| The Firehose mirror intentionally does not participate in `/ready`. A temporary AWS failure increments `ingestion_firehose_records_total{status="error"}` and is logged, while the primary ingestion response continues to reflect the primary warehouse write. If the bounded queue fills, records are dropped from the mirror and counted with `status="dropped"` rather than allowing telemetry backpressure to take down ingestion. | ||
|
|
||
| ## Provision the AWS resources | ||
|
|
||
| Prerequisites: | ||
|
|
||
| - Terraform 1.6+ | ||
| - AWS credentials available through the standard AWS credential chain | ||
| - Permission to create S3, Firehose, CloudWatch Logs, IAM policy/role, and ECR resources | ||
|
|
||
| ```bash | ||
| cd terraform | ||
| terraform init | ||
| terraform fmt -check | ||
| terraform validate | ||
| terraform plan | ||
| terraform apply | ||
| ``` | ||
|
|
||
| The default configuration creates: | ||
|
|
||
| - a private, versioned S3 bucket with SSE-S3 encryption; | ||
| - a 30-day telemetry lifecycle policy; | ||
| - an Amazon Data Firehose delivery stream named `sentinelai-telemetry`; | ||
| - 60-second / 5-MiB buffering with GZIP compression; | ||
| - time-partitioned S3 keys under `inference/year=.../month=.../day=.../hour=.../`; | ||
| - a CloudWatch log group for Firehose delivery errors; | ||
| - a Firehose service role with only the S3 and CloudWatch permissions it needs; | ||
| - a separate `sentinelai-firehose-writer` IAM policy granting `PutRecord` and `PutRecordBatch` to the SentinelAI workload; | ||
| - the existing SentinelAI ECR repository, now defined in a valid Terraform `.tf` file. | ||
|
|
||
| Use `terraform output` after apply to retrieve the generated S3 bucket name, stream ARN, stream name, and writer-policy ARN. | ||
|
|
||
| ## Give the ingestion workload permission | ||
|
|
||
| Do not put long-lived AWS access keys in the repository. Attach the Terraform output `firehose_writer_policy_arn` to the workload identity used by SentinelAI. On EKS, the intended production pattern is an IAM role associated with the ingestion service account (IRSA / EKS workload identity). | ||
|
|
||
| For local development against a real AWS account, the AWS SDK for Go v2 uses its normal credential provider chain. Keep credentials outside the repo. | ||
|
|
||
| ## Enable the mirror | ||
|
|
||
| ```bash | ||
| export FIREHOSE_ENABLED=true | ||
| export FIREHOSE_DELIVERY_STREAM=sentinelai-telemetry | ||
| export FIREHOSE_QUEUE_SIZE=1000 | ||
| export AWS_REGION=us-east-1 | ||
| ``` | ||
|
|
||
| Then start SentinelAI and send a normal inference log: | ||
|
|
||
| ```bash | ||
| curl -X POST http://localhost:8080/log \ | ||
| -H "Content-Type: application/json" \ | ||
| -d '{"model_id":"demo","model_version":"v1","latency_ms":120,"tokens_in":32,"tokens_out":64,"status":"ok"}' | ||
| ``` | ||
|
|
||
| The producer appends a newline to every JSON record before `PutRecord`. That keeps individual events parseable after Firehose concatenates buffered records into S3 objects. | ||
|
|
||
| ## Observability | ||
|
|
||
| The ingestion service exports: | ||
|
|
||
| ```text | ||
| ingestion_firehose_records_total{status="queued"} | ||
| ingestion_firehose_records_total{status="delivered"} | ||
| ingestion_firehose_records_total{status="error"} | ||
| ingestion_firehose_records_total{status="dropped"} | ||
| ``` | ||
|
|
||
| CloudWatch delivery logs cover the managed Firehose-to-S3 leg. Application metrics cover the producer-side queue and `PutRecord` result. | ||
|
|
||
| ## Failure semantics | ||
|
|
||
| This feature is a telemetry mirror, not a transactional dual-write guarantee. The in-memory queue is intentionally bounded and is not persisted across process termination. AWS SDK retries may also produce duplicate Firehose records in some failure scenarios. Consumers should therefore treat the S3 telemetry dataset as at-least-once/best-effort observability data and use stable event identifiers if strict de-duplication is later required. | ||
|
|
||
| ## Cost control | ||
|
|
||
| Firehose, S3, CloudWatch Logs, and related data transfer can incur AWS charges. The default 30-day S3 expiration and 14-day CloudWatch log retention are intended to keep a portfolio/dev deployment bounded. Review the Terraform plan and AWS pricing before leaving the stack running. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,144 @@ | ||
| package main | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "errors" | ||
| "fmt" | ||
| "log" | ||
| "os" | ||
| "strconv" | ||
| "strings" | ||
| "time" | ||
|
|
||
| "github.com/aws/aws-sdk-go-v2/config" | ||
| "github.com/aws/aws-sdk-go-v2/service/firehose" | ||
| "github.com/aws/aws-sdk-go-v2/service/firehose/types" | ||
| "github.com/prometheus/client_golang/prometheus" | ||
| ) | ||
|
|
||
| const ( | ||
| defaultFirehoseQueueSize = 1000 | ||
| firehosePublishTimeout = 5 * time.Second | ||
| ) | ||
|
|
||
| var firehoseRecordsTotal = prometheus.NewCounterVec( | ||
| prometheus.CounterOpts{ | ||
| Name: "ingestion_firehose_records_total", | ||
| Help: "Inference telemetry records handled by the optional Amazon Data Firehose mirror.", | ||
| }, | ||
| []string{"status"}, | ||
| ) | ||
|
|
||
| func init() { | ||
| prometheus.MustRegister(firehoseRecordsTotal) | ||
| } | ||
|
|
||
| type firehoseAPI interface { | ||
| PutRecord(context.Context, *firehose.PutRecordInput, ...func(*firehose.Options)) (*firehose.PutRecordOutput, error) | ||
| } | ||
|
|
||
| type firehosePublisher struct { | ||
| client firehoseAPI | ||
| streamName string | ||
| } | ||
|
|
||
| type firehoseDispatcher struct { | ||
| publisher *firehosePublisher | ||
| queue chan InferenceLog | ||
| } | ||
|
|
||
| func newFirehoseDispatcher() (*firehoseDispatcher, error) { | ||
| if !strings.EqualFold(strings.TrimSpace(os.Getenv("FIREHOSE_ENABLED")), "true") { | ||
| return nil, nil | ||
| } | ||
|
|
||
| streamName := strings.TrimSpace(os.Getenv("FIREHOSE_DELIVERY_STREAM")) | ||
| if streamName == "" { | ||
| return nil, errors.New("FIREHOSE_DELIVERY_STREAM is required when FIREHOSE_ENABLED=true") | ||
| } | ||
|
|
||
| region := strings.TrimSpace(os.Getenv("AWS_REGION")) | ||
| if region == "" { | ||
| region = "us-east-1" | ||
| } | ||
|
|
||
| queueSize := defaultFirehoseQueueSize | ||
| if raw := strings.TrimSpace(os.Getenv("FIREHOSE_QUEUE_SIZE")); raw != "" { | ||
| parsed, err := strconv.Atoi(raw) | ||
| if err != nil || parsed < 1 { | ||
| return nil, fmt.Errorf("FIREHOSE_QUEUE_SIZE must be a positive integer: %q", raw) | ||
| } | ||
| queueSize = parsed | ||
| } | ||
|
|
||
| cfg, err := config.LoadDefaultConfig(context.Background(), config.WithRegion(region)) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("load AWS configuration: %w", err) | ||
| } | ||
|
|
||
| dispatcher := &firehoseDispatcher{ | ||
| publisher: &firehosePublisher{ | ||
| client: firehose.NewFromConfig(cfg), | ||
| streamName: streamName, | ||
| }, | ||
| queue: make(chan InferenceLog, queueSize), | ||
| } | ||
| go dispatcher.run() | ||
|
|
||
| return dispatcher, nil | ||
| } | ||
|
|
||
| func (d *firehoseDispatcher) enqueue(entry InferenceLog) { | ||
| select { | ||
| case d.queue <- entry: | ||
| firehoseRecordsTotal.WithLabelValues("queued").Inc() | ||
| default: | ||
| firehoseRecordsTotal.WithLabelValues("dropped").Inc() | ||
| log.Printf("firehose mirror queue full; dropping telemetry record for model=%s", entry.ModelID) | ||
| } | ||
| } | ||
|
|
||
| func (d *firehoseDispatcher) run() { | ||
| for entry := range d.queue { | ||
| ctx, cancel := context.WithTimeout(context.Background(), firehosePublishTimeout) | ||
| err := d.publisher.publish(ctx, entry) | ||
| cancel() | ||
|
|
||
| if err != nil { | ||
| firehoseRecordsTotal.WithLabelValues("error").Inc() | ||
| log.Printf("firehose PutRecord failed for model=%s: %v", entry.ModelID, err) | ||
| continue | ||
| } | ||
| firehoseRecordsTotal.WithLabelValues("delivered").Inc() | ||
| } | ||
| } | ||
|
|
||
| func (p *firehosePublisher) publish(ctx context.Context, entry InferenceLog) error { | ||
| payload, err := encodeFirehoseRecord(entry) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| _, err = p.client.PutRecord(ctx, &firehose.PutRecordInput{ | ||
| DeliveryStreamName: &p.streamName, | ||
| Record: &types.Record{ | ||
| Data: payload, | ||
| }, | ||
| }) | ||
| if err != nil { | ||
| return fmt.Errorf("put record: %w", err) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func encodeFirehoseRecord(entry InferenceLog) ([]byte, error) { | ||
| payload, err := json.Marshal(entry) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("marshal inference log: %w", err) | ||
| } | ||
|
|
||
| // Firehose concatenates records inside delivered objects. NDJSON keeps each | ||
| // inference event independently parseable after buffering and compression. | ||
| return append(payload, '\n'), nil | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the mirror is enabled through the documented local
docker compose upflow, this environment block forwards the feature settings and region but none of the AWS credential/profile variables, and it does not mount the host's shared AWS configuration. Consequently, credentials available through the host's normal SDK chain are invisible inside the container and everyPutRecordfails while/logstill returns accepted. Provide a secure credential or workload-role forwarding mechanism, or document the required Compose override.Useful? React with 👍 / 👎.