Skip to content

feat[Go]: implement Langfuse API key management endpoints#15677

Open
hunnyboy1217 wants to merge 2 commits into
infiniflow:mainfrom
hunnyboy1217:feat/go-langfuse-api
Open

feat[Go]: implement Langfuse API key management endpoints#15677
hunnyboy1217 wants to merge 2 commits into
infiniflow:mainfrom
hunnyboy1217:feat/go-langfuse-api

Conversation

@hunnyboy1217
Copy link
Copy Markdown
Contributor

@hunnyboy1217 hunnyboy1217 commented Jun 4, 2026

What problem does this PR solve?

Closes #15675 — ports three Langfuse credential-management endpoints from Python (api/apps/restful_apis/langfuse_api.py) to Go, following the layered architecture specified in the issue.

Method Path Handler
POST / PUT /api/v1/langfuse/api-key LangfuseHandler.SetAPIKey
GET /api/v1/langfuse/api-key LangfuseHandler.GetAPIKey
DELETE /api/v1/langfuse/api-key LangfuseHandler.DeleteAPIKey

Type of change

  • New Feature (non-breaking change which adds functionality)

Implementation notes

Files created (per the architecture in the issue):

internal/entity/langfuse.go   – TenantLangfuse (→ tenant_langfuse table)
internal/dao/langfuse.go      – LangfuseDAO CRUD
internal/service/langfuse.go  – LangfuseService + Langfuse HTTP helpers
internal/handler/langfuse.go  – Three HTTP handlers
internal/router/router.go     – Router wiring + four routes

Functional parity table:

Concern Go behaviour
Required fields secret_key, public_key, host all checked; missing any → "Missing required fields" — mirrors Python @validate_request + explicit check
Auth check langfuseAuthCheck: GET {host}/api/public/projects with Basic Auth (public_key:secret_key), 7 s timeout — mirrors langfuse.auth_check()
Upsert LangfuseDAO.GetByTenantID → Create or UpdateByTenantID — mirrors Python filter_by_tenant + save / update_by_tenant
Secret not leaked on set Response includes only tenant_id, public_key, host — secret never echoed
Secret not leaked on get GET response includes public_key, host, project_id, project_name; secret_key intentionally excluded — satisfies issue requirement
Project info langfuseGetProjects: same GET /api/public/projects call, parses data[0].id and data[0].name — mirrors Python langfuse.api.projects.get()
No keys stored GET / DELETE return "Have not record any Langfuse keys." — exact Python message
Delete Hard-delete via LangfuseDAO.DeleteByTenantID — mirrors TenantLangfuseService.delete_model
Auth middleware All three routes sit inside the authorized group (same as all other v1 routes)

@dosubot dosubot Bot added the size:XL This PR changes 500-999 lines, ignoring generated files. label Jun 4, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 4, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds tenant-scoped Langfuse credential management: GORM model and DAO, LangfuseService with external Basic-Auth validation and SSRF protections, Gin handlers for set/get/delete, and router endpoints at /api/v1/langfuse/api-key.

Changes

Langfuse API Credential Management

Layer / File(s) Summary
Entity and data persistence
internal/entity/langfuse.go, internal/dao/langfuse.go
TenantLangfuse GORM model mapped to tenant_langfuse; LangfuseDAO implements tenant-scoped GetByTenantID, Create, UpdateByTenantID, UpsertByTenantID, and DeleteByTenantID (hard delete).
Service business logic and Langfuse HTTP integration
internal/service/langfuse.go
LangfuseService with SetAPIKey/GetAPIKey/DeleteAPIKey, performs Basic-Auth auth-check and projects fetch from Langfuse, validates host, uses an SSRF-guarded HTTP transport, upserts/deletes tenant records, and omits secret_key from responses.
HTTP endpoint handlers
internal/handler/langfuse.go
LangfuseHandler exposes SetAPIKey, GetAPIKey, and DeleteAPIKey endpoints; resolves tenant user, binds requests, calls service methods, and returns JSON payloads or error codes.
Router wiring and endpoints
internal/router/router.go
Router gains langfuseHandler, initialized in NewRouter, and registers authenticated /api/v1/langfuse/api-key endpoints for POST/PUT, GET, and DELETE.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Handler
  participant Service
  participant LangfuseAPI
  participant DAO
  participant DB
  Client->>Handler: POST/PUT /api/v1/langfuse/api-key
  Handler->>Service: SetAPIKey(tenantID, req)
  Service->>LangfuseAPI: Basic-Auth GET /api/public/projects (auth-check)
  LangfuseAPI-->>Service: 2xx or error
  Service->>DAO: UpsertByTenantID
  DAO->>DB: INSERT/UPDATE tenant_langfuse
  DB-->>DAO: OK/error
  Service-->>Handler: response (metadata without secret)
  Handler-->>Client: JSON success/error
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

size:L, lgtm

Suggested reviewers

  • Haruko386

Poem

🐰 I hopped through tenant lanes to find,
Keys tucked safe, no secrets left behind.
DAO burrows deep and Service knocks the gate,
Handlers answer routes, and tests await.
Projects peek out—now everything's aligned!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main feature: implementing Langfuse API key management endpoints in Go, directly matching the PR's primary objective.
Description check ✅ Passed The description follows the template with both required sections completed: a clear problem statement (closes #15675) and correct type selection (New Feature). Additional implementation notes provide helpful context.
Linked Issues check ✅ Passed The PR successfully implements all three Langfuse API endpoints required by #15675, with per-tenant credential storage, Langfuse validation, secret_key protection, and proper layered architecture following the Go reference layout.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the three Langfuse credential-management endpoints and their supporting infrastructure (entity, DAO, service, handler, router) as specified in #15675. No extraneous modifications detected.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/entity/langfuse.go`:
- Line 23: Change the TenantLangfuse.SecretKey struct tag so it is not output
during default JSON marshaling: replace json:"secret_key" with json:"-" on the
SecretKey field in the TenantLangfuse struct; if you still need to expose the
secret in controlled contexts, add an explicit accessor method (e.g.,
GetSecretKey()) or implement a custom marshaler rather than relying on the
struct tag.

In `@internal/service/langfuse.go`:
- Around line 67-76: The SetAPIKey flow currently trusts the user-controlled
host (used later in langfuseAuthCheck and outbound HTTP calls), so add a strict
host validator function (e.g., validateLangfuseHost or isSafeLangfuseHost) and
call it early in SetAPIKey before any network activity and reuse it in outbound
helpers; the validator must enforce allowed scheme (https only), normalize/parse
the host, resolve DNS and reject IPs that are private/loopback/link-local or
resolve to internal ranges, and reject suspicious hostnames (e.g., raw IPs or
local names) so SSRF/internal access is prevented when calling langfuseAuthCheck
and other functions that use req.Host.
- Around line 78-103: The current read-then-create/update in SetAPIKey (uses
s.langfuseDAO.GetByTenantID, Create, UpdateByTenantID) is racy; replace it with
an atomic upsert either by adding a DAO method like UpsertByTenantID that issues
a single DB operation (use GORM's Clauses.OnConflict/DoUpdate or a transaction
with INSERT ... ON CONFLICT) to set secret_key, public_key, host and tenant_id
(ensure tenant_id has a unique constraint), or perform the upsert inside a DB
transaction in the service layer; update the call in langfuse.go to call that
new atomic Upsert method instead of the read-then-create/update flow.
- Around line 183-185: In langfuseAuthCheck, replace the unbounded drain
(io.Copy(io.Discard, resp.Body)) with a bounded drain to avoid resource
exhaustion; use an io.LimitReader or io.CopyN with a defined maxDrainBytes
constant (e.g., 64KB or a reasonable limit) so only up to that many bytes are
read from resp.Body before closing, and optionally note/truncate if the body was
larger—keep the defer resp.Body.Close() and ensure the new bounded reader is
used in the io.Copy call.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ce488816-dca8-4d6a-abc1-2073f3d7c6e6

📥 Commits

Reviewing files that changed from the base of the PR and between 96a4166 and 68e131c.

📒 Files selected for processing (5)
  • internal/dao/langfuse.go
  • internal/entity/langfuse.go
  • internal/handler/langfuse.go
  • internal/router/router.go
  • internal/service/langfuse.go

Comment thread internal/entity/langfuse.go Outdated
Comment on lines +67 to +76
func (s *LangfuseService) SetAPIKey(tenantID string, req *SetAPIKeyRequest) (map[string]interface{}, error) {
if strings.TrimSpace(req.SecretKey) == "" ||
strings.TrimSpace(req.PublicKey) == "" ||
strings.TrimSpace(req.Host) == "" {
return nil, fmt.Errorf("Missing required fields")
}

if err := langfuseAuthCheck(req.PublicKey, req.SecretKey, req.Host); err != nil {
return nil, fmt.Errorf("Invalid Langfuse keys")
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

Validate and constrain host before any outbound request.

host is user-controlled and used directly for server-side HTTP calls. This enables SSRF against internal network targets unless the host is constrained (scheme, hostname, and private/loopback/link-local resolution checks).

Suggested hardening direction
 func (s *LangfuseService) SetAPIKey(tenantID string, req *SetAPIKeyRequest) (map[string]interface{}, error) {
+	if err := validateLangfuseHost(req.Host); err != nil {
+		return nil, fmt.Errorf("Invalid host")
+	}
 	if strings.TrimSpace(req.SecretKey) == "" ||
 		strings.TrimSpace(req.PublicKey) == "" ||
 		strings.TrimSpace(req.Host) == "" {
 		return nil, fmt.Errorf("Missing required fields")
 	}
// Add a strict validator (scheme + hostname + DNS/IP safety checks)
// and reuse it in both SetAPIKey and outbound helpers for defense in depth.

Also applies to: 166-169, 193-196

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/langfuse.go` around lines 67 - 76, The SetAPIKey flow
currently trusts the user-controlled host (used later in langfuseAuthCheck and
outbound HTTP calls), so add a strict host validator function (e.g.,
validateLangfuseHost or isSafeLangfuseHost) and call it early in SetAPIKey
before any network activity and reuse it in outbound helpers; the validator must
enforce allowed scheme (https only), normalize/parse the host, resolve DNS and
reject IPs that are private/loopback/link-local or resolve to internal ranges,
and reject suspicious hostnames (e.g., raw IPs or local names) so SSRF/internal
access is prevented when calling langfuseAuthCheck and other functions that use
req.Host.

Comment thread internal/service/langfuse.go Outdated
Comment thread internal/service/langfuse.go
Ports three Langfuse credential endpoints from Python (langfuse_api.py)
to Go, closing issue infiniflow#15675:

  POST/PUT /api/v1/langfuse/api-key   (LangfuseHandler.SetAPIKey)
  GET      /api/v1/langfuse/api-key   (LangfuseHandler.GetAPIKey)
  DELETE   /api/v1/langfuse/api-key   (LangfuseHandler.DeleteAPIKey)

New files:
  internal/entity/langfuse.go   – TenantLangfuse entity (tenant_langfuse table)
  internal/dao/langfuse.go      – LangfuseDAO: GetByTenantID, Create,
                                   UpdateByTenantID, DeleteByTenantID
  internal/service/langfuse.go  – LangfuseService: SetAPIKey, GetAPIKey,
                                   DeleteAPIKey; langfuseAuthCheck and
                                   langfuseGetProjects HTTP helpers
  internal/handler/langfuse.go  – LangfuseHandler with three HTTP handlers

Changed files:
  internal/router/router.go     – Router struct gains langfuseHandler;
                                   NewRouter constructs it; four routes added

Functional parity with Python:
  SetAPIKey   – validates all three fields; calls GET {host}/api/public/projects
                with Basic Auth (mirrors langfuse.auth_check()); upserts record;
                secret_key not echoed in response
  GetAPIKey   – validates stored keys; fetches project list from Langfuse;
                returns public_key, host, project_id, project_name;
                secret_key intentionally excluded from response
  DeleteAPIKey – returns "Have not record" message when no keys stored,
                 otherwise hard-deletes and returns true
@hunnyboy1217 hunnyboy1217 force-pushed the feat/go-langfuse-api branch from 68e131c to b70d600 Compare June 5, 2026 04:24
@dosubot dosubot Bot added size:L This PR changes 100-499 lines, ignoring generated files. and removed size:XL This PR changes 500-999 lines, ignoring generated files. labels Jun 5, 2026
@hunnyboy1217
Copy link
Copy Markdown
Contributor Author

Hi, @Haruko386 ,
Could you please review this PR?

Applies CodeRabbit's final-review feedback.

1. Secret key leakage (entity/langfuse.go): TenantLangfuse.SecretKey is now
   tagged json:"-" so it can never leak through any path that marshals the
   entity directly.

2. SSRF host guard (service/langfuse.go): the user-supplied Langfuse host is now
   validated (http/https scheme + hostname) and all outbound calls go through an
   HTTP client whose dialer resolves the host, rejects any non-globally-routable
   IP (loopback/private/link-local/multicast/unspecified/CGNAT), and pins the
   validated address — closing the authenticated-SSRF / DNS-rebinding vector.
   Note: this rejects self-hosted Langfuse on private networks (a deliberate
   security/usability tradeoff; relax isGloballyRoutableIP if internal hosts must
   be allowed).

3. Upsert race (dao + service): SetAPIKey's read-then-create/update is replaced
   by a single atomic LangfuseDAO.UpsertByTenantID using GORM clause.OnConflict
   on the tenant_id unique index, removing the concurrent-write race.

4. Unbounded drain (service/langfuse.go): langfuseAuthCheck now drains the
   response through io.LimitReader (1 MB cap) instead of an unbounded
   io.Copy(io.Discard, resp.Body); the outbound client also carries an explicit
   timeout.
@dosubot dosubot Bot added size:XL This PR changes 500-999 lines, ignoring generated files. and removed size:L This PR changes 100-499 lines, ignoring generated files. labels Jun 5, 2026
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
internal/service/langfuse.go (1)

113-120: ⚡ Quick win

Remove the redundant auth-check call in GetAPIKey.

Line 113 does langfuseAuthCheck, then Line 117 calls langfuseGetProjects against the same endpoint with the same credentials. The second call already validates auth; removing the first cuts external calls and latency.

Suggested simplification
-	if err := langfuseAuthCheck(entry.PublicKey, entry.SecretKey, entry.Host); err != nil {
-		return nil, fmt.Errorf("Invalid Langfuse keys loaded")
-	}
-
 	projects, err := langfuseGetProjects(entry.PublicKey, entry.SecretKey, entry.Host)
 	if err != nil {
-		return nil, fmt.Errorf("Error from Langfuse: %w", err)
+		return nil, fmt.Errorf("Invalid Langfuse keys loaded")
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/langfuse.go` around lines 113 - 120, Remove the redundant
authentication call in GetAPIKey: the explicit call to
langfuseAuthCheck(entry.PublicKey, entry.SecretKey, entry.Host) is unnecessary
because langfuseGetProjects(entry.PublicKey, entry.SecretKey, entry.Host)
performs the same auth validation; delete the langfuseAuthCheck call and its
error branch so the function only calls langfuseGetProjects and handles its
error/response.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/service/langfuse.go`:
- Around line 236-238: The URL scheme check in the Langfuse host validator (the
u.Scheme conditional in internal/service/langfuse.go) currently permits "http"
and "https" which can leak Basic Auth credentials; change the logic to only
accept "https" and return an error if u.Scheme != "https". If you need to allow
"http" for local/dev testing, gate that exception behind an explicit, clearly
named opt-in (e.g. an environment flag like ALLOW_INSECURE_LANGFUSE or a
function IsDevAllowlist) and document/validate that flag before permitting
"http".
- Around line 263-276: The custom dialer closure currently pins the first
resolved IP (variable pinned) and returns it, causing failures if that IP is
down; instead iterate the resolved ips slice, skip any failing
isGloballyRoutableIP checks, and for each valid ip call dialer.DialContext(ctx,
network, net.JoinHostPort(ip.String(), port)) sequentially returning the first
successful connection; if all attempts fail, return a combined or last error and
if no addresses were valid return the existing "host resolved to no addresses"
error—update the DialContext closure logic that references pinned, ips,
isGloballyRoutableIP, and dialer.DialContext accordingly.

---

Nitpick comments:
In `@internal/service/langfuse.go`:
- Around line 113-120: Remove the redundant authentication call in GetAPIKey:
the explicit call to langfuseAuthCheck(entry.PublicKey, entry.SecretKey,
entry.Host) is unnecessary because langfuseGetProjects(entry.PublicKey,
entry.SecretKey, entry.Host) performs the same auth validation; delete the
langfuseAuthCheck call and its error branch so the function only calls
langfuseGetProjects and handles its error/response.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 50c2a439-5d69-4d6a-a040-3feb0226e28a

📥 Commits

Reviewing files that changed from the base of the PR and between b70d600 and 0a81124.

📒 Files selected for processing (3)
  • internal/dao/langfuse.go
  • internal/entity/langfuse.go
  • internal/service/langfuse.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/entity/langfuse.go

Comment on lines +236 to +238
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("Langfuse host must use http or https")
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Require HTTPS for Langfuse host validation.

Line 236 currently allows http, but this flow sends Basic Auth credentials and can leak public_key:secret_key in transit. Restrict to https (or gate http behind an explicit non-production allowlist).

Suggested fix
-	if u.Scheme != "http" && u.Scheme != "https" {
-		return fmt.Errorf("Langfuse host must use http or https")
+	if u.Scheme != "https" {
+		return fmt.Errorf("Langfuse host must use https")
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if u.Scheme != "http" && u.Scheme != "https" {
return fmt.Errorf("Langfuse host must use http or https")
}
if u.Scheme != "https" {
return fmt.Errorf("Langfuse host must use https")
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/langfuse.go` around lines 236 - 238, The URL scheme check in
the Langfuse host validator (the u.Scheme conditional in
internal/service/langfuse.go) currently permits "http" and "https" which can
leak Basic Auth credentials; change the logic to only accept "https" and return
an error if u.Scheme != "https". If you need to allow "http" for local/dev
testing, gate that exception behind an explicit, clearly named opt-in (e.g. an
environment flag like ALLOW_INSECURE_LANGFUSE or a function IsDevAllowlist) and
document/validate that flag before permitting "http".

Comment on lines +263 to +276
var pinned net.IP
for _, ip := range ips {
if !isGloballyRoutableIP(ip) {
return nil, fmt.Errorf("Langfuse host resolves to a non-public address (%s)", ip)
}
if pinned == nil {
pinned = ip
}
}
if pinned == nil {
return nil, fmt.Errorf("host %q resolved to no addresses", host)
}
return dialer.DialContext(ctx, network, net.JoinHostPort(pinned.String(), port))
},
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid single-IP pinning in the custom dialer.

Line 268 pins only the first resolved IP. If that IP is down but others are healthy, requests fail unnecessarily. Try validated IPs sequentially before returning failure.

Suggested fix
-				var pinned net.IP
-				for _, ip := range ips {
+				for _, ip := range ips {
 					if !isGloballyRoutableIP(ip) {
 						return nil, fmt.Errorf("Langfuse host resolves to a non-public address (%s)", ip)
 					}
-					if pinned == nil {
-						pinned = ip
-					}
 				}
-				if pinned == nil {
+				if len(ips) == 0 {
 					return nil, fmt.Errorf("host %q resolved to no addresses", host)
 				}
-				return dialer.DialContext(ctx, network, net.JoinHostPort(pinned.String(), port))
+				var lastErr error
+				for _, ip := range ips {
+					conn, err := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
+					if err == nil {
+						return conn, nil
+					}
+					lastErr = err
+				}
+				return nil, fmt.Errorf("could not connect to resolved addresses for %q: %w", host, lastErr)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/service/langfuse.go` around lines 263 - 276, The custom dialer
closure currently pins the first resolved IP (variable pinned) and returns it,
causing failures if that IP is down; instead iterate the resolved ips slice,
skip any failing isGloballyRoutableIP checks, and for each valid ip call
dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
sequentially returning the first successful connection; if all attempts fail,
return a combined or last error and if no addresses were valid return the
existing "host resolved to no addresses" error—update the DialContext closure
logic that references pinned, ips, isGloballyRoutableIP, and dialer.DialContext
accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Subtask]: Implement Langfuse APIs in Go

1 participant