# sh-openswe-traces LangSmith Bulk Export destination for the Open SWE deployment. A **storage-only** stack — no compute. LangSmith runs the export on its own schedule and writes Parquet run/trace data into this bucket; we retain it for periodic auditing and prompt/instruction improvement (query with Athena). Owned by **HCP Terraform** in seahaven-prod (`sh-openswe-traces-prod`). Migrated from mgmt SAM under [PLAT-73](https://seahaven.atlassian.net/browse/PLAT-73). ## Why this exists LangSmith retains traces for ~14 days. To audit agent behavior over longer horizons and mine it for prompt improvements, we own the data in S3 rather than paying for extended LangSmith retention. Capture is LangSmith-native Bulk Export; this repo is just the destination and its access controls. ## Architecture ``` LangSmith (Bulk Export, scheduled LangSmith-side) │ s3:PutObject (IAM user access key, least-privilege) ▼ s3://sh-openswe-traces-/langsmith/… │ SSE-KMS (alias/sh-openswe-traces) │ lifecycle: → DEEP_ARCHIVE @ 90d ▼ Athena / manual audit ``` | Resource | Name | Notes | |---|---|---| | S3 bucket | `sh-openswe-traces-` | BPA all-on, SSE-KMS (default), versioned, TLS-only, `prevent_destroy` | | Log bucket | `sh-openswe-traces-logs-` | S3 server access logs (SSE-S3); logs expire 365d | | KMS CMK | `alias/sh-openswe-traces` | Rotation on; bucket default + writer encrypt through it | | IAM user | `sh-openswe-langsmith-export` | Write-only LangSmith writer; `PutObject` bucket-wide, no read/delete | | Secret | `sh-openswe/langsmith-export-s3` | Writer's access key (aws/secretsmanager key); populated post-apply | Account-suffixed bucket names avoid global S3 name collision with the retired mgmt buckets during cutover. ## Deploy (HCP Terraform) Workspace: `sh-openswe-traces-prod` (org `seahaven`, project `seahaven-prod`). Working directory: `terraform/`. Apply method: **Manual** until sealed. Roles: `hcptf-sh-openswe-traces-plan` / `hcptf-sh-openswe-traces` (org-baseline terraform-substrate). ```bash cd terraform terraform init terraform plan # First apply is Manual from the HCP UI (or terraform apply after plan confirm). ``` If the secret shell was created out-of-band (checklist step 1), import before the first apply that manages it: ```bash terraform import aws_secretsmanager_secret.export_key \ 'arn:aws:secretsmanager:us-east-1:011934824531:secret:sh-openswe/langsmith-export-s3-OQoqqU' ``` ### Workspace env (once) Workspace-level only (never project-scoped variable sets): - `TFC_AWS_PROVIDER_AUTH=true` - `TFC_AWS_PLAN_ROLE_ARN=arn:aws:iam::011934824531:role/hcptf-sh-openswe-traces-plan` - `TFC_AWS_APPLY_ROLE_ARN=arn:aws:iam::011934824531:role/hcptf-sh-openswe-traces` ### Post-apply: mint and store the writer access key The stack creates the IAM user and an empty secret; the access key is minted out-of-band so it never lands in Terraform state. **Run this only on a trusted single-user workstation, never in CI** — it handles a live credential. ```bash USER=$(terraform -chdir=terraform output -raw export_user_name) KEY_JSON=$(aws iam create-access-key --user-name "$USER" --profile seahaven-prod \ | jq '{AccessKeyId: .AccessKey.AccessKeyId, SecretAccessKey: .AccessKey.SecretAccessKey}') # Pass the secret via stdin, not argv, so it never appears in the process table. # Strip trailing newlines — a trailing \n breaks HTTP headers at runtime. printf '%s' "$KEY_JSON" | aws secretsmanager put-secret-value \ --secret-id sh-openswe/langsmith-export-s3 \ --secret-string file:///dev/stdin \ --profile seahaven-prod \ --region us-east-1 unset KEY_JSON ``` ### Configure LangSmith Bulk Export Driven by the LangSmith API (Plus/Enterprise only). Needs `LS_API_KEY` (LangSmith API key) and `LS_TENANT` (workspace id). Run **after** the writer key is minted/stored — the destination call validates by test-writing to the bucket. **1. Create the destination** (creds pulled from Secrets Manager, never pasted): ```bash BUCKET=$(terraform -chdir=terraform output -raw bucket_name) CREDS=$(aws secretsmanager get-secret-value --secret-id sh-openswe/langsmith-export-s3 \ --profile seahaven-prod --region us-east-1 \ --query SecretString --output text) AKID=$(jq -r .AccessKeyId <<<"$CREDS"); SAK=$(jq -r .SecretAccessKey <<<"$CREDS") curl -sS -X POST 'https://api.smith.langchain.com/api/v1/bulk-exports/destinations' \ -H 'Content-Type: application/json' -H "X-API-Key: $LS_API_KEY" -H "X-Tenant-Id: $LS_TENANT" \ --data @- <' '' '' '' ) # all 4, or the subset you audit for PID in "${PROJECTS[@]}"; do curl -sS -X POST 'https://api.smith.langchain.com/api/v1/bulk-exports' \ -H 'Content-Type: application/json' -H "X-API-Key: $LS_API_KEY" -H "X-Tenant-Id: $LS_TENANT" \ --data @- </…`. ### Monitor exports ```bash # Every export in the workspace: id, project, schedule, status curl -sS 'https://api.smith.langchain.com/api/v1/bulk-exports' \ -H "X-API-Key: $LS_API_KEY" -H "X-Tenant-Id: $LS_TENANT" \ | jq -r '(.bulk_exports // .exports // .)[] | [.id, (.session_id // "all_experiments"), (if .interval_hours then "every \(.interval_hours)h" else "one-off" end), .status] | @tsv' | column -t ``` - A recurring schedule's own status is `IntervalScheduled`; the daily child exports it spawns have `CREATED` / `RUNNING` / `COMPLETED` / `FAILED` / `CANCELLED` / `TIMEDOUT` (child exports carry `source_bulk_export_id`). - One export's detail: `GET /api/v1/bulk-exports/` — its per-run rows: `GET /api/v1/bulk-exports//runs`. - **Stop a schedule:** `PATCH /api/v1/bulk-exports/` with `{"status":"Cancelled"}`. Already-spawned child exports must be cancelled separately, and a cancelled job can't be restarted — create a new one. ## Operations - **Rotate** the writer access key quarterly: `aws iam create-access-key`, update the secret + LangSmith destination (`PATCH`/recreate), then delete the old key. Key age is monitored account-wide by the Security Hub `ACCESS_KEYS_ROTATED` Config rule (flags at 90d) — rotation keeps it compliant. - **Audit**: point Athena at `s3://sh-openswe-traces-/langsmith/` (Parquet). Objects older than 90 days are in Deep Archive — restore before querying. - **Read attribution**: object access is logged to `s3://sh-openswe-traces-logs-/s3-access/` (S3 server access logging). - **Cost**: Deep Archive ≈ $1/TB/mo; expect the archive to dominate storage cost. ## Gotchas (from IAM cross-review) - **Writer needs `kms:Decrypt`.** SSE-KMS multipart uploads call `kms:Decrypt` at `CompleteMultipartUpload`; without it every multipart export fails `AccessDenied`. It's granted and safe — the writer has no `s3:GetObject`, so nothing to exfiltrate. - **No ACL headers.** The bucket is `BucketOwnerEnforced`; any PutObject carrying an ACL header is rejected with `AccessControlListNotSupported` (not fixable in policy). boto3/most SDKs send none by default — verify LangSmith's exporter likewise. - **Downstream readers need their own CMK grant.** An Athena/Glue role reading the Parquet needs explicit `kms:Decrypt` (+ `kms:GenerateDataKey`) on `alias/sh-openswe-traces` — see `reference_kms_cmk_grant_migration`. ## Security Traces can contain source code and secrets surfaced in tool I/O. Controls: SSE-KMS at rest (customer-managed CMK, bucket default), versioning (overwrite recovery), Block Public Access, TLS-only bucket policy, a write-only least-privilege writer (bucket-wide `PutObject` + `kms:Decrypt` gated to `kms:ViaService=s3`, no read/delete), the credential secret on a separate managed key, and S3 access logging. SAM `template.yaml` / `bootstrap.yaml` and the frozen GitHub `deploy.yaml` are retained only until mgmt cutover completes; do not re-enable SAM CD. Deferred, non-blocking: CloudTrail S3 data-events (org trail carries none; access logging covers attribution for now).