diff --git a/.gitignore b/.gitignore index 4efddc2..1e07617 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ # Dependency directories (remove the comment below to include it) vendor .env +.vscode/ +.local/ diff --git a/README.md b/README.md index 900452a..b0e53eb 100644 --- a/README.md +++ b/README.md @@ -11,16 +11,16 @@ Inspired by [uber-go/dig](https://github.com/uber-go/dig), with support for adva ## ✨ Features -| Feature | Description | -|---------|-------------| -| 🔄 **Cycle Detection** | Auto-detect dependency cycles | -| 📦 **Multiple Injection** | Support func, struct, map, list | -| 🏷️ **Namespace** | Dependency isolation via map key | -| 🎯 **Multi-Output** | Struct can provide multiple dependencies | -| 🪆 **Nested Support** | Support nested struct injection | -| 🔧 **Non-Invasive** | Zero intrusion to original objects | -| 🛡️ **Safe API** | `TryProvide`/`TryInject` won't panic | -| 🌐 **Visualization** | HTTP module for dependency graph | +| Feature | Description | +| ------------------------ | ---------------------------------------- | +| 🔄 **Cycle Detection** | Auto-detect dependency cycles | +| 📦 **Multiple Injection** | Support func, struct, map, list | +| 🏷️ **Namespace** | Dependency isolation via map key | +| 🎯 **Multi-Output** | Struct can provide multiple dependencies | +| 🪆 **Nested Support** | Support nested struct injection | +| 🔧 **Non-Invasive** | Zero intrusion to original objects | +| 🛡️ **Safe API** | `TryProvide`/`TryInject` won't panic | +| 🌐 **Visualization** | HTTP module for dependency graph | ## 📦 Installation @@ -115,6 +115,99 @@ err := dix.TryInject(di, func(svc *Service) { }) ``` +### Startup Timeout / Slow Provider Warning + +Control long-running providers during startup: + +- Default provider timeout: `15s` +- Disable provider timeout explicitly: `dix.WithProviderTimeout(0)` +- Default slow provider warning threshold: `2s` +- Disable slow provider warning: `dix.WithSlowProviderThreshold(0)` + +```go +di := dix.New( + // Default `ProviderTimeout` is `15s` + // Use `dix.WithProviderTimeout(0)` to disable provider timeout + // Default `SlowProviderThreshold` is `2s` + // Use `dix.WithSlowProviderThreshold(0)` to disable slow-provider warnings + dix.WithProviderTimeout(2*time.Second), // override default (default: 15s, 0 = disabled) + dix.WithSlowProviderThreshold(300*time.Millisecond), // override default (default: 2s, 0 = disabled) +) +``` + +### DI Trace Logging (Optional) + +Enable step-by-step dependency resolution/injection/provider execution logs: + +- Env var: `DIX_TRACE_DI` +- Default: disabled +- Enable values: `1`, `true`, `on`, `yes`, `enable`, `trace`, `debug` + +```bash +export DIX_TRACE_DI=true +``` + +When enabled, dix prints `di_trace ...` events with structured key-values (provider, input/output types, query kind, parent chain, timeout, etc.). + +> Note: if `DIX_LLM_DIAG_MODE=machine`, human-readable text logs are suppressed by design, including `di_trace` lines. + +### Diagnostic File Collection (Optional) + +You can collect detailed diagnostics into a searchable JSONL file: + +- Env var: `DIX_DIAG_FILE` +- Example: `export DIX_DIAG_FILE=.local/dix-diag.jsonl` + +Behavior rules: + +- If `DIX_DIAG_FILE` is **not configured**, dix keeps the original behavior (no diagnostic file output). +- If `DIX_DIAG_FILE` is configured, dix appends diagnostic records to file (`trace` / `error` / `llm`). +- Console verbosity still follows existing controls (`DIX_TRACE_DI`, `DIX_LLM_DIAG_MODE`). + +Tip: + +- Keep console output concise for users. +- Keep detailed records in file for search/LLM/offline troubleshooting. + +### In-Memory Trace Query (`dixtrace`, Optional) + +Starting from this version, dix also emits unified trace events into an in-memory trace store (`dixtrace`), which can be queried via HTTP API (`/api/trace`). + +- Default: enabled (in-memory ring buffer) +- Optional file sink env var: `DIX_TRACE_FILE` +- Example: `export DIX_TRACE_FILE=.local/dix-trace.jsonl` +- Compatibility fallback: when `DIX_TRACE_FILE` is not set and `DIX_DIAG_FILE` is set, trace file sink will reuse `DIX_DIAG_FILE` in append mode. + +`/api/trace` is optimized for online troubleshooting (filter by `operation/status/event/component/provider/output_type`). +If you need separate trace-only file persistence, set `DIX_TRACE_FILE` explicitly. + +Quick event dictionary: + +| Event | Meaning | +| ---------------------------------------------- | -------------------------------------------------------------------------- | +| `di_trace inject.start` | Begin an injection request (`component`, `param_type`) | +| `di_trace inject.route` | Injection route selected (`function` or `struct`) | +| `di_trace provide.start` | Begin a provider registration request (`component`) | +| `di_trace provide.signature` | Provider function signature analyzed (`input_count`, `output_count`) | +| `di_trace provide.register.output.done` | Provider output type registered successfully | +| `di_trace provide.register.failed` | Provider registration failed (`reason` or `error`) | +| `di_trace resolve.value.search_provider.start` | Start searching providers for a dependency type | +| `di_trace resolve.value.found` | Dependency value resolved successfully | +| `di_trace resolve.value.not_found` | Dependency resolution failed (`reason` included) | +| `di_trace provider.execute.dispatch` | Provider selected for execution (`provider`, `output_type`, `input_types`) | +| `di_trace provider.input.resolve.start` | Resolve one provider input type | +| `di_trace provider.input.resolve.found` | Provider input resolved | +| `di_trace provider.input.resolve.failed` | Provider input resolution failed | +| `di_trace provider.call.start` | Start executing provider (`timeout`) | +| `di_trace provider.call.done` | Provider execution completed | +| `di_trace provider.call.failed` | Provider execution failed (`timed_out`, `error`) | +| `di_trace provider.call.return_error` | Provider returned non-nil `error` | +| `di_trace inject.func.resolve_input.start` | Resolve function injection argument | +| `di_trace inject.func.resolve_input.failed` | Function argument resolution failed | +| `di_trace inject.struct.field.resolve.start` | Resolve one struct field injection | +| `di_trace inject.struct.field.resolve.done` | Struct field injected successfully | +| `di_trace inject.struct.field.resolve.failed` | Struct field injection failed | + ## 🎯 Injection Patterns ### Struct Injection @@ -242,29 +335,29 @@ task build ## 📚 Examples -| Example | Description | -|---------|-------------| -| [struct-in](./example/struct-in/) | Struct input injection | -| [struct-out](./example/struct-out/) | Struct multi-output | -| [func](./example/func/) | Function injection | -| [map](./example/map/) | Map/namespace injection | -| [map-nil](./example/map-nil/) | Map with nil handling | -| [list](./example/list/) | List injection | -| [list-nil](./example/list-nil/) | List with nil handling | -| [lazy](./example/lazy/) | Lazy injection | -| [cycle](./example/cycle/) | Cycle detection example | -| [handler](./example/handler/) | Handler pattern | -| [inject_method](./example/inject_method/) | Method injection | -| [test-return-error](./example/test-return-error/) | Error handling | -| [http](./example/http/) | HTTP visualization | +| Example | Description | +| ------------------------------------------------- | ----------------------- | +| [struct-in](./example/struct-in/) | Struct input injection | +| [struct-out](./example/struct-out/) | Struct multi-output | +| [func](./example/func/) | Function injection | +| [map](./example/map/) | Map/namespace injection | +| [map-nil](./example/map-nil/) | Map with nil handling | +| [list](./example/list/) | List injection | +| [list-nil](./example/list-nil/) | List with nil handling | +| [lazy](./example/lazy/) | Lazy injection | +| [cycle](./example/cycle/) | Cycle detection example | +| [handler](./example/handler/) | Handler pattern | +| [inject_method](./example/inject_method/) | Method injection | +| [test-return-error](./example/test-return-error/) | Error handling | +| [http](./example/http/) | HTTP visualization | ## 📖 Documentation -| Document | Description | -|----------|-------------| -| [Design Document](./docs/design.md) | Architecture and detailed design | -| [Audit Report](./docs/audit.md) | Project audit, evaluation and comparison | -| [dixhttp README](./dixhttp/README.md) | HTTP visualization module documentation | +| Document | Description | +| ------------------------------------- | ---------------------------------------- | +| [Design Document](./docs/design.md) | Architecture and detailed design | +| [Audit Report](./docs/audit.md) | Project audit, evaluation and comparison | +| [dixhttp README](./dixhttp/README.md) | HTTP visualization module documentation | ## 📄 License diff --git a/README_zh.md b/README_zh.md index 7cfb34a..8d93f32 100644 --- a/README_zh.md +++ b/README_zh.md @@ -11,16 +11,16 @@ ## ✨ 功能特性 -| 特性 | 说明 | -|------|------| -| 🔄 **循环检测** | 自动检测依赖循环,避免死循环 | -| 📦 **多种注入** | 支持 func、struct、map、list 作为注入参数 | -| 🏷️ **命名空间** | 通过 map key 实现依赖隔离 | -| 🎯 **多输出** | struct 可对外提供多组依赖对象 | -| 🪆 **嵌套支持** | 支持 struct 依赖嵌套 | -| 🔧 **无侵入** | 对原对象零侵入 | +| 特性 | 说明 | +| -------------- | ------------------------------------------------- | +| 🔄 **循环检测** | 自动检测依赖循环,避免死循环 | +| 📦 **多种注入** | 支持 func、struct、map、list 作为注入参数 | +| 🏷️ **命名空间** | 通过 map key 实现依赖隔离 | +| 🎯 **多输出** | struct 可对外提供多组依赖对象 | +| 🪆 **嵌套支持** | 支持 struct 依赖嵌套 | +| 🔧 **无侵入** | 对原对象零侵入 | | 🛡️ **安全 API** | 提供 `TryProvide`/`TryInject` 不 panic 的安全版本 | -| 🌐 **可视化** | HTTP 模块图形化展示依赖关系 | +| 🌐 **可视化** | HTTP 模块图形化展示依赖关系 | ## 📦 安装 @@ -115,6 +115,99 @@ err := dix.TryInject(di, func(svc *Service) { }) ``` +### 启动超时 / 慢 Provider 告警 + +可在启动阶段限制 provider 执行时间,并对慢调用输出告警: + +- 默认 `ProviderTimeout` 为 `15s` +- 可用 `dix.WithProviderTimeout(0)` 显式关闭 provider 超时 +- 默认 `SlowProviderThreshold` 为 `2s` +- 可用 `dix.WithSlowProviderThreshold(0)` 显式关闭慢 provider 告警 + +```go +di := dix.New( + // 默认 `ProviderTimeout` 为 `15s` + // 使用 `dix.WithProviderTimeout(0)` 可关闭 provider 超时 + // 默认 `SlowProviderThreshold` 为 `2s` + // 使用 `dix.WithSlowProviderThreshold(0)` 可关闭慢 provider 告警 + dix.WithProviderTimeout(2*time.Second), // 覆盖默认值(default: 15s, 0 = 不限制) + dix.WithSlowProviderThreshold(300*time.Millisecond), // 覆盖默认值(default: 2s, 0 = 关闭) +) +``` + +### DI 追踪日志(可选) + +可开启“依赖查询 / 注入 / Provider 执行”的全过程日志: + +- 环境变量:`DIX_TRACE_DI` +- 默认:关闭 +- 开启取值:`1`、`true`、`on`、`yes`、`enable`、`trace`、`debug` + +```bash +export DIX_TRACE_DI=true +``` + +开启后会输出 `di_trace ...` 事件,包含结构化键值(如 provider、输入输出类型、查询类型、父链路、超时等)。 + +> 注意:若设置 `DIX_LLM_DIAG_MODE=machine`,会按设计抑制人类文本日志,`di_trace` 也会被抑制。 + +### 诊断文件采集(可选) + +你可以把更完整的诊断信息写入可检索的 JSONL 文件: + +- 环境变量:`DIX_DIAG_FILE` +- 示例:`export DIX_DIAG_FILE=.local/dix-diag.jsonl` + +行为规则: + +- 如果 **未配置** `DIX_DIAG_FILE`,dix 保持原有方案(不输出诊断文件)。 +- 如果配置了 `DIX_DIAG_FILE`,dix 会追加写入诊断记录(`trace` / `error` / `llm`)。 +- 终端可见日志仍由现有开关控制(`DIX_TRACE_DI`、`DIX_LLM_DIAG_MODE`)。 + +建议: + +- 终端给用户看“少而准”。 +- 文件给排障/LLM看“全而细”。 + +### 内存 Trace 查询(`dixtrace`,可选) + +从这个版本开始,dix 会把统一 trace 事件写入内存 trace 存储(`dixtrace`),可通过 HTTP API(`/api/trace`)在线查询。 + +- 默认:开启(内存环形缓冲) +- 可选文件落盘环境变量:`DIX_TRACE_FILE` +- 示例:`export DIX_TRACE_FILE=.local/dix-trace.jsonl` +- 兼容回退:当未配置 `DIX_TRACE_FILE` 且已配置 `DIX_DIAG_FILE` 时,trace 文件落盘会复用 `DIX_DIAG_FILE`(追加写入模式)。 + +`/api/trace` 适合在线排障(按 `operation/status/event/component/provider/output_type` 等过滤)。 +如需独立的 trace 专用文件,请显式配置 `DIX_TRACE_FILE`。 + +事件速查: + +| 事件 | 含义 | +| ---------------------------------------------- | ----------------------------------------------------------------------- | +| `di_trace inject.start` | 开始一次注入请求(`component`、`param_type`) | +| `di_trace inject.route` | 注入路径已确定(`function` 或 `struct`) | +| `di_trace provide.start` | 开始一次 provider 注册请求(`component`) | +| `di_trace provide.signature` | provider 函数签名分析完成(`input_count`、`output_count`) | +| `di_trace provide.register.output.done` | provider 输出类型注册成功 | +| `di_trace provide.register.failed` | provider 注册失败(含 `reason` 或 `error`) | +| `di_trace resolve.value.search_provider.start` | 开始为某个依赖类型查找 provider | +| `di_trace resolve.value.found` | 依赖值查找成功 | +| `di_trace resolve.value.not_found` | 依赖值查找失败(含 `reason`) | +| `di_trace provider.execute.dispatch` | 选择并派发 provider 执行(含 `provider`、`output_type`、`input_types`) | +| `di_trace provider.input.resolve.start` | 开始解析 provider 的某个输入 | +| `di_trace provider.input.resolve.found` | provider 输入解析成功 | +| `di_trace provider.input.resolve.failed` | provider 输入解析失败 | +| `di_trace provider.call.start` | 开始执行 provider(含 `timeout`) | +| `di_trace provider.call.done` | provider 执行完成 | +| `di_trace provider.call.failed` | provider 执行失败(含 `timed_out`、`error`) | +| `di_trace provider.call.return_error` | provider 返回了非 nil `error` | +| `di_trace inject.func.resolve_input.start` | 开始解析函数注入参数 | +| `di_trace inject.func.resolve_input.failed` | 函数注入参数解析失败 | +| `di_trace inject.struct.field.resolve.start` | 开始解析结构体字段注入 | +| `di_trace inject.struct.field.resolve.done` | 结构体字段注入成功 | +| `di_trace inject.struct.field.resolve.failed` | 结构体字段注入失败 | + ## 🎯 注入模式 ### 结构体注入 @@ -242,29 +335,29 @@ task build ## 📚 示例 -| 示例 | 说明 | -|------|------| -| [struct-in](./example/struct-in/) | 结构体输入注入 | -| [struct-out](./example/struct-out/) | 结构体多输出 | -| [func](./example/func/) | 函数注入 | -| [map](./example/map/) | Map/命名空间注入 | -| [map-nil](./example/map-nil/) | Map 空值处理 | -| [list](./example/list/) | List 注入 | -| [list-nil](./example/list-nil/) | List 空值处理 | -| [lazy](./example/lazy/) | 延迟注入 | -| [cycle](./example/cycle/) | 循环检测示例 | -| [handler](./example/handler/) | Handler 模式 | -| [inject_method](./example/inject_method/) | 方法注入 | -| [test-return-error](./example/test-return-error/) | 错误处理 | -| [http](./example/http/) | HTTP 可视化 | +| 示例 | 说明 | +| ------------------------------------------------- | ---------------- | +| [struct-in](./example/struct-in/) | 结构体输入注入 | +| [struct-out](./example/struct-out/) | 结构体多输出 | +| [func](./example/func/) | 函数注入 | +| [map](./example/map/) | Map/命名空间注入 | +| [map-nil](./example/map-nil/) | Map 空值处理 | +| [list](./example/list/) | List 注入 | +| [list-nil](./example/list-nil/) | List 空值处理 | +| [lazy](./example/lazy/) | 延迟注入 | +| [cycle](./example/cycle/) | 循环检测示例 | +| [handler](./example/handler/) | Handler 模式 | +| [inject_method](./example/inject_method/) | 方法注入 | +| [test-return-error](./example/test-return-error/) | 错误处理 | +| [http](./example/http/) | HTTP 可视化 | ## 📖 文档 -| 文档 | 说明 | -|------|------| -| [设计文档](./docs/design_zh.md) | 架构和详细设计 | -| [审计报告](./docs/audit_zh.md) | 项目审计、评价和对比 | -| [dixhttp 文档](./dixhttp/README_zh.md) | HTTP 可视化模块文档 | +| 文档 | 说明 | +| -------------------------------------- | -------------------- | +| [设计文档](./docs/design_zh.md) | 架构和详细设计 | +| [审计报告](./docs/audit_zh.md) | 项目审计、评价和对比 | +| [dixhttp 文档](./dixhttp/README_zh.md) | HTTP 可视化模块文档 | ## 📄 License diff --git a/dix.go b/dix.go index 696c8a8..9dd43f5 100644 --- a/dix.go +++ b/dix.go @@ -1,9 +1,11 @@ package dix import ( + "context" _ "embed" "log/slog" "reflect" + "time" "github.com/pubgo/dix/v2/dixinternal" ) @@ -23,6 +25,14 @@ func SetLog(log slog.Handler) { dixinternal.SetLog(log) } func WithValuesNull() Option { return dixinternal.WithValuesNull() } +func WithProviderTimeout(timeout time.Duration) Option { + return dixinternal.WithProviderTimeout(timeout) +} + +func WithSlowProviderThreshold(threshold time.Duration) Option { + return dixinternal.WithSlowProviderThreshold(threshold) +} + func New(opts ...Option) *Dix { return dixinternal.New(opts...) } func Inject[T any](di *Dix, data T, opts ...Option) T { @@ -36,6 +46,17 @@ func Inject[T any](di *Dix, data T, opts ...Option) T { return data } +func InjectContext[T any](ctx context.Context, di *Dix, data T, opts ...Option) T { + vp := reflect.ValueOf(data) + if vp.Kind() == reflect.Struct { + _ = di.InjectContext(ctx, &data, opts...) + } else { + _ = di.InjectContext(ctx, data, opts...) + } + + return data +} + func InjectT[T any](di *Dix, opts ...Option) T { var data T if reflect.TypeOf(data).Kind() != reflect.Struct { @@ -46,4 +67,14 @@ func InjectT[T any](di *Dix, opts ...Option) T { return data } +func InjectTContext[T any](ctx context.Context, di *Dix, opts ...Option) T { + var data T + if reflect.TypeOf(data).Kind() != reflect.Struct { + panic(" type kind is not struct") + } + + _ = di.InjectContext(ctx, &data, opts...) + return data +} + func Provide(di *Dix, data any) { di.Provide(data) } diff --git a/dixhttp/README.md b/dixhttp/README.md index 3e679f7..864326f 100644 --- a/dixhttp/README.md +++ b/dixhttp/README.md @@ -15,6 +15,9 @@ This module provides an HTTP server to visualize dependency relationships in the - 🧩 **Group Rules (Prefix Aggregation)** - Aggregate nodes by package/prefix rules - 🔎 **Prefix Filter** - Show only nodes/providers matching a prefix - 🧭 **Group Subgraph** - View a group's internal + upstream/downstream dependencies +- ⏱️ **Startup Runtime Stats** - Show all providers' startup durations (total/avg/last, call count), with executed-only filter (`call_count > 0`) +- 🗂 **Diagnostic File Query** - When `DIX_DIAG_FILE` is set, UI can query and display `trace/error/llm` JSONL records for troubleshooting +- 🧵 **Trace Timeline Query** - Query unified in-memory trace events from `dixtrace` via `/api/trace` (with rich filters); for file persistence, `DIX_TRACE_FILE` is preferred, and falls back to `DIX_DIAG_FILE` when unset - 📡 **RESTful API** - Provide JSON format dependency data - 🧩 **Mermaid Export/Preview** - Generate Mermaid flowcharts for current graph (respects grouping/filtering) @@ -97,11 +100,11 @@ server := dixhttp.NewServerWithOptions( ### Three-Panel Layout -| Area | Description | -|------|-------------| -| **Left - Package List** | Provider list grouped by package, searchable, collapsible | -| **Center - Dependency Graph** | Interactive graph with drag, zoom, click support | -| **Right - Details Panel** | Show selected node details with clickable navigation | +| Area | Description | +| ----------------------------- | --------------------------------------------------------- | +| **Left - Package List** | Provider list grouped by package, searchable, collapsible | +| **Center - Dependency Graph** | Interactive graph with drag, zoom, click support | +| **Right - Details Panel** | Show selected node details with clickable navigation | ## Core Features @@ -146,12 +149,12 @@ After searching or clicking a type, the system shows that type as center: Depth determines how many levels to expand up/down: -| Depth | Description | Use Case | -|-------|-------------|----------| -| 1 | Only direct dependencies/dependents | Quick view of direct relationships | -| 2 | Two levels (default) | Recommended for daily use | -| 3-5 | More levels | Track complex dependency chains | -| All | Show complete dependency tree | Small projects or specific analysis | +| Depth | Description | Use Case | +| ----- | ----------------------------------- | ----------------------------------- | +| 1 | Only direct dependencies/dependents | Quick view of direct relationships | +| 2 | Two levels (default) | Recommended for daily use | +| 3-5 | More levels | Track complex dependency chains | +| All | Show complete dependency tree | Small projects or specific analysis | **Example**: Assume dependency chain is `Config → Database → UserService → Handler` @@ -215,13 +218,13 @@ Left panel features: ## Interactions -| Operation | Effect | -|-----------|--------| -| **Single Click** | Show details in right panel | -| **Double Click** | Show dependency graph centered on that node | -| **Drag Node** | Move node position | -| **Scroll Zoom** | Zoom in/out graph | -| **Click Type in Details** | Jump to view that type's dependencies | +| Operation | Effect | +| ------------------------- | ------------------------------------------- | +| **Single Click** | Show details in right panel | +| **Double Click** | Show dependency graph centered on that node | +| **Drag Node** | Move node position | +| **Scroll Zoom** | Zoom in/out graph | +| **Click Type in Details** | Jump to view that type's dependencies | ## Mermaid Support @@ -249,6 +252,110 @@ Returns summary statistics } ``` +### GET `/api/runtime-stats?limit=20` +Returns provider runtime metrics sorted by total duration (desc), useful for finding slow startup components. + +```json +[ + { + "function_name": "main.NewUserService", + "output_type": "*service.UserService", + "call_count": 1, + "total_duration": 3456789, + "average_duration": 3456789, + "last_duration": 3456789, + "last_run_at_unix_nano": 1700000000000000000 + } +] +``` + +### GET `/api/errors?limit=50` +Returns recent `Inject` / `TryInject` errors (latest first), useful when startup injection fails before full initialization. + +```json +[ + { + "operation": "provider_execute", + "component": "main.main.func12", + "stage": "resolve_input", + "provider_function": "main.main.func12", + "output_type": "*main.UserService", + "input_type": "*main.Database", + "root_cause": "value not found: type=*main.Database ...", + "message": "failed to get input value for provider: value not found: type=*main.Database ...", + "occurred_at_unix_nano": 1700000000000000000 + } +] +``` + +### GET `/api/diagnostics?kind=trace&q=provider&event=provider.call.start&limit=200` +Reads and filters JSONL records from `DIX_DIAG_FILE`. + +If `DIX_DIAG_FILE` is not set, response returns `enabled=false` and empty records. + +```json +{ + "enabled": true, + "path": "/tmp/dix-diag.jsonl", + "exists": true, + "total": 42, + "returned": 42, + "next_before_id": 0, + "records": [ + { + "record_id": 128, + "source": "dix", + "pid": 12345, + "process": "my-app", + "hostname": "dev-mac", + "trace_di": true, + "llm_diag_mode": "dual", + "kind": "trace", + "event": "provider.call.start", + "occurred_at_unix_nano": 1700000000000000000, + "fields": { + "provider": "github.com/acme/app.main.NewDB" + } + } + ] +} +``` + +### GET `/api/trace?operation=provider&status=error&limit=200` +Returns in-memory unified trace events from `dixtrace`. + +File sink behavior: + +- Prefer `DIX_TRACE_FILE` when configured. +- If `DIX_TRACE_FILE` is unset and `DIX_DIAG_FILE` is set, trace file sink reuses `DIX_DIAG_FILE` in append mode (single-file troubleshooting setup). + +Supported filters: + +- `trace_id`, `operation`, `status`, `event`, `component`, `provider`, `output_type`, `q` +- `limit`, `before_id`, `since_unix_nano`, `until_unix_nano` + +```json +{ + "enabled": true, + "total": 2, + "returned": 2, + "records": [ + { + "id": 102, + "operation": "provider", + "phase": "call.failed", + "event": "provider.call.failed", + "status": "error", + "provider_function": "github.com/acme/app.main.NewDB", + "output_type": "*db.Client", + "error": "dial tcp timeout", + "timed_out": true, + "occurred_at_unix_nano": 1700000000000000000 + } + ] +} +``` + ### GET `/api/packages` Returns package list diff --git a/dixhttp/README_zh.md b/dixhttp/README_zh.md index 228133f..2158eb5 100644 --- a/dixhttp/README_zh.md +++ b/dixhttp/README_zh.md @@ -15,6 +15,8 @@ - 🧩 **分组清单(前缀聚合)** - 通过包路径/前缀聚合节点 - 🔎 **前缀过滤** - 只显示匹配前缀的节点/Provider - 🧭 **组内子图** - 查看组内及上下游依赖 +- 🗂 **诊断文件查询** - 配置 `DIX_DIAG_FILE` 后,页面可查询并展示 `trace/error/llm` JSONL 记录 +- 🧵 **Trace 时间线查询** - 通过 `/api/trace` 查询 `dixtrace` 内存统一事件(支持多维过滤);文件持久化优先使用 `DIX_TRACE_FILE`,未配置时回退复用 `DIX_DIAG_FILE` - 📡 **RESTful API** - 提供 JSON 格式的依赖关系数据 - 🧩 **Mermaid 预览/导出** - 将当前图生成 Mermaid 流程图(支持分组/过滤) @@ -97,11 +99,11 @@ server := dixhttp.NewServerWithOptions( ### 三栏布局 -| 区域 | 说明 | -|------|------| -| **左侧 - 包列表** | 按包分组的 Provider 列表,支持搜索过滤,可折叠收起 | -| **中间 - 依赖图** | 交互式依赖关系图,支持拖拽、缩放、点击 | -| **右侧 - 详情面板** | 显示选中节点的详细信息,可点击跳转 | +| 区域 | 说明 | +| ------------------- | -------------------------------------------------- | +| **左侧 - 包列表** | 按包分组的 Provider 列表,支持搜索过滤,可折叠收起 | +| **中间 - 依赖图** | 交互式依赖关系图,支持拖拽、缩放、点击 | +| **右侧 - 详情面板** | 显示选中节点的详细信息,可点击跳转 | ## 核心功能 @@ -146,12 +148,12 @@ server := dixhttp.NewServerWithOptions( 深度决定了向上/向下展开多少层依赖: -| 深度 | 说明 | 适用场景 | -|------|------|---------| -| 1 | 只显示直接依赖/被依赖 | 快速查看直接关系 | -| 2 | 显示两层关系(默认) | 日常使用推荐 | -| 3-5 | 显示更多层级 | 追踪复杂依赖链 | -| 全部 | 展示完整依赖树 | 小型项目或特定分析 | +| 深度 | 说明 | 适用场景 | +| ---- | --------------------- | ------------------ | +| 1 | 只显示直接依赖/被依赖 | 快速查看直接关系 | +| 2 | 显示两层关系(默认) | 日常使用推荐 | +| 3-5 | 显示更多层级 | 追踪复杂依赖链 | +| 全部 | 展示完整依赖树 | 小型项目或特定分析 | **示例**:假设依赖链是 `Config → Database → UserService → Handler` @@ -214,13 +216,13 @@ dixhttp.RegisterGroupRules( ## 交互操作 -| 操作 | 效果 | -|------|------| -| **单击节点** | 右侧显示详情 | -| **双击节点** | 以该节点为中心展示依赖图 | -| **拖拽节点** | 移动节点位置 | -| **滚轮缩放** | 放大/缩小图形 | -| **点击详情中的类型** | 跳转查看该类型的依赖 | +| 操作 | 效果 | +| -------------------- | ------------------------ | +| **单击节点** | 右侧显示详情 | +| **双击节点** | 以该节点为中心展示依赖图 | +| **拖拽节点** | 移动节点位置 | +| **滚轮缩放** | 放大/缩小图形 | +| **点击详情中的类型** | 跳转查看该类型的依赖 | ## Mermaid 支持 @@ -282,6 +284,93 @@ dixhttp.RegisterGroupRules( } ``` +### GET `/api/errors?limit=50` +返回最近的 `Inject` / `TryInject` 错误(按时间倒序),用于在启动阶段注入失败后继续排查。 + +```json +[ + { + "operation": "provider_execute", + "component": "main.main.func12", + "stage": "resolve_input", + "provider_function": "main.main.func12", + "output_type": "*main.UserService", + "input_type": "*main.Database", + "root_cause": "value not found: type=*main.Database ...", + "message": "failed to get input value for provider: value not found: type=*main.Database ...", + "occurred_at_unix_nano": 1700000000000000000 + } +] +``` + +### GET `/api/diagnostics?kind=trace&q=provider&event=provider.call.start&limit=200` +读取并过滤 `DIX_DIAG_FILE` 中的 JSONL 记录。 + +若未配置 `DIX_DIAG_FILE`,返回 `enabled=false` 且记录为空。 + +```json +{ + "enabled": true, + "path": "/tmp/dix-diag.jsonl", + "exists": true, + "total": 42, + "returned": 42, + "next_before_id": 0, + "records": [ + { + "record_id": 128, + "source": "dix", + "pid": 12345, + "process": "my-app", + "hostname": "dev-mac", + "trace_di": true, + "llm_diag_mode": "dual", + "kind": "trace", + "event": "provider.call.start", + "occurred_at_unix_nano": 1700000000000000000, + "fields": { + "provider": "github.com/acme/app.main.NewDB" + } + } + ] +} +``` + +### GET `/api/trace?operation=provider&status=error&limit=200` +返回来自 `dixtrace` 的内存统一 trace 事件。 + +文件落盘行为: + +- 优先使用 `DIX_TRACE_FILE`(若已配置)。 +- 当未配置 `DIX_TRACE_FILE` 且已配置 `DIX_DIAG_FILE` 时,trace 文件落盘会以追加模式复用 `DIX_DIAG_FILE`(单文件排查配置)。 + +支持过滤参数: + +- `trace_id`, `operation`, `status`, `event`, `component`, `provider`, `output_type`, `q` +- `limit`, `before_id`, `since_unix_nano`, `until_unix_nano` + +```json +{ + "enabled": true, + "total": 2, + "returned": 2, + "records": [ + { + "id": 102, + "operation": "provider", + "phase": "call.failed", + "event": "provider.call.failed", + "status": "error", + "provider_function": "github.com/acme/app.main.NewDB", + "output_type": "*db.Client", + "error": "dial tcp timeout", + "timed_out": true, + "occurred_at_unix_nano": 1700000000000000000 + } + ] +} +``` + ### GET `/api/package/{packageName}` 返回指定包内的 Provider 详情 diff --git a/dixhttp/server.go b/dixhttp/server.go index eeaf73a..5a2515c 100644 --- a/dixhttp/server.go +++ b/dixhttp/server.go @@ -1,6 +1,7 @@ package dixhttp import ( + "bytes" _ "embed" "encoding/json" "fmt" @@ -10,6 +11,7 @@ import ( "sync" "github.com/pubgo/dix/v2/dixinternal" + "github.com/pubgo/dix/v2/dixtrace" ) //go:embed template.html @@ -129,12 +131,104 @@ func (s *Server) setupRoutes() { s.mux.HandleFunc(indexPath, s.HandleIndex) s.mux.HandleFunc(base+"/api/dependencies", s.HandleDependencies) s.mux.HandleFunc(base+"/api/stats", s.HandleStats) + s.mux.HandleFunc(base+"/api/runtime-stats", s.HandleRuntimeStats) + s.mux.HandleFunc(base+"/api/errors", s.HandleErrors) + s.mux.HandleFunc(base+"/api/diagnostics", s.HandleDiagnostics) + s.mux.HandleFunc(base+"/api/trace", s.HandleTrace) s.mux.HandleFunc(base+"/api/packages", s.HandlePackages) s.mux.HandleFunc(base+"/api/package/", s.HandlePackageDetails) s.mux.HandleFunc(base+"/api/type/", s.HandleTypeDetails) s.mux.HandleFunc(base+"/api/group-rules", s.HandleGroupRules) } +// HandleErrors returns recent Inject/TryInject error events. +// Query params: +// - limit: optional positive integer to limit returned rows. +func (s *Server) HandleErrors(w http.ResponseWriter, r *http.Request) { + limit := 0 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + errors := s.dix.GetRecentErrors(limit) + writeJSON(w, errors) +} + +// HandleDiagnostics returns records from DIX_DIAG_FILE (JSONL). +// Query params: +// - kind: trace|error|llm (optional) +// - event: trace event fuzzy match (optional) +// - q: full-text search over record JSON (optional) +// - limit: optional positive integer, default 200, max 2000 +// - before_id: optional record id cursor for older-page query +// - since_unix_nano: optional lower time bound +// - until_unix_nano: optional upper time bound +func (s *Server) HandleDiagnostics(w http.ResponseWriter, r *http.Request) { + query := dixinternal.DiagFileQuery{ + Kind: strings.TrimSpace(r.URL.Query().Get("kind")), + Event: strings.TrimSpace(r.URL.Query().Get("event")), + Search: strings.TrimSpace(r.URL.Query().Get("q")), + } + + if limitStr := strings.TrimSpace(r.URL.Query().Get("limit")); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil { + query.Limit = l + } + } + + if beforeStr := strings.TrimSpace(r.URL.Query().Get("before_id")); beforeStr != "" { + if before, err := strconv.ParseInt(beforeStr, 10, 64); err == nil { + query.BeforeID = before + } + } + + if sinceStr := strings.TrimSpace(r.URL.Query().Get("since_unix_nano")); sinceStr != "" { + if since, err := strconv.ParseInt(sinceStr, 10, 64); err == nil { + query.SinceUnix = since + } + } + + if untilStr := strings.TrimSpace(r.URL.Query().Get("until_unix_nano")); untilStr != "" { + if until, err := strconv.ParseInt(untilStr, 10, 64); err == nil { + query.UntilUnix = until + } + } + + result, err := dixinternal.ReadDiagFileRecords(query) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read diagnostics: %v", err), http.StatusInternalServerError) + return + } + + writeJSON(w, result) +} + +// HandleTrace returns in-memory dixtrace events. +// Query params: +// - trace_id, operation, status, event, component, provider, output_type, q +// - limit, before_id, since_unix_nano, until_unix_nano +func (s *Server) HandleTrace(w http.ResponseWriter, r *http.Request) { + params := map[string]any{ + "trace_id": strings.TrimSpace(r.URL.Query().Get("trace_id")), + "operation": strings.TrimSpace(r.URL.Query().Get("operation")), + "status": strings.TrimSpace(r.URL.Query().Get("status")), + "event": strings.TrimSpace(r.URL.Query().Get("event")), + "component": strings.TrimSpace(r.URL.Query().Get("component")), + "provider": strings.TrimSpace(r.URL.Query().Get("provider")), + "output_type": strings.TrimSpace(r.URL.Query().Get("output_type")), + "q": strings.TrimSpace(r.URL.Query().Get("q")), + "limit": strings.TrimSpace(r.URL.Query().Get("limit")), + "before_id": strings.TrimSpace(r.URL.Query().Get("before_id")), + "since_unix_nano": strings.TrimSpace(r.URL.Query().Get("since_unix_nano")), + "until_unix_nano": strings.TrimSpace(r.URL.Query().Get("until_unix_nano")), + } + + result := dixtrace.QueryEvents(dixtrace.ParseQueryFromMap(params)) + writeJSON(w, result) +} + // ServeHTTP implements http.Handler interface func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) @@ -191,6 +285,25 @@ func (s *Server) HandleStats(w http.ResponseWriter, r *http.Request) { writeJSON(w, stats) } +// HandleRuntimeStats returns provider runtime stats for startup/perf diagnosis. +// Query params: +// - limit: optional positive integer to limit returned rows. +func (s *Server) HandleRuntimeStats(w http.ResponseWriter, r *http.Request) { + limit := 0 + if limitStr := r.URL.Query().Get("limit"); limitStr != "" { + if l, err := strconv.Atoi(limitStr); err == nil && l > 0 { + limit = l + } + } + + stats := s.dix.GetProviderRuntimeStats() + if limit > 0 && len(stats) > limit { + stats = stats[:limit] + } + + writeJSON(w, stats) +} + // HandlePackages returns list of packages for navigation func (s *Server) HandlePackages(w http.ResponseWriter, r *http.Request) { providerDetails := s.dix.GetProviderDetails() @@ -238,7 +351,12 @@ func (s *Server) HandlePackages(w http.ResponseWriter, r *http.Request) { // HandlePackageDetails returns details for a specific package func (s *Server) HandlePackageDetails(w http.ResponseWriter, r *http.Request) { // Extract package name from URL - pkgName := strings.TrimPrefix(r.URL.Path, "/api/package/") + prefix := s.basePath + "/api/package/" + pkgName := strings.TrimPrefix(r.URL.Path, prefix) + if pkgName == r.URL.Path { + http.Error(w, "package name required", http.StatusBadRequest) + return + } if pkgName == "" { http.Error(w, "package name required", http.StatusBadRequest) return @@ -295,7 +413,12 @@ func (s *Server) HandlePackageDetails(w http.ResponseWriter, r *http.Request) { // HandleTypeDetails returns dependency details for a specific type func (s *Server) HandleTypeDetails(w http.ResponseWriter, r *http.Request) { // Extract type name from URL - typeName := strings.TrimPrefix(r.URL.Path, "/api/type/") + prefix := s.basePath + "/api/type/" + typeName := strings.TrimPrefix(r.URL.Path, prefix) + if typeName == r.URL.Path { + http.Error(w, "type name required", http.StatusBadRequest) + return + } if typeName == "" { http.Error(w, "type name required", http.StatusBadRequest) return @@ -437,6 +560,8 @@ func (s *Server) extractDependencyData(pkgFilter string, limit int) *DependencyD OutputPkg: detail.OutputPkg, FunctionName: detail.FunctionName, FunctionPkg: detail.FunctionPkg, + FunctionFile: detail.FunctionFile, + FunctionLine: detail.FunctionLine, InputTypes: detail.InputTypes, InputPkgs: detail.InputPkgs, } @@ -545,6 +670,8 @@ type ProviderInfo struct { OutputPkg string `json:"output_pkg"` FunctionName string `json:"function_name"` FunctionPkg string `json:"function_pkg"` + FunctionFile string `json:"function_file"` + FunctionLine int `json:"function_line"` InputTypes []string `json:"input_types"` InputPkgs []string `json:"input_pkgs"` } @@ -605,12 +732,15 @@ func extractPackage(typeName string) string { } func writeJSON(w http.ResponseWriter, data any) { - w.Header().Set("Content-Type", "application/json; charset=utf-8") - w.WriteHeader(http.StatusOK) - - if err := json.NewEncoder(w).Encode(data); err != nil { + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(data); err != nil { http.Error(w, fmt.Sprintf("Failed to encode JSON: %v", err), http.StatusInternalServerError) + return } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(buf.Bytes()) } func normalizeBasePath(basePath string) string { diff --git a/dixhttp/server_runtime_test.go b/dixhttp/server_runtime_test.go new file mode 100644 index 0000000..ccdabde --- /dev/null +++ b/dixhttp/server_runtime_test.go @@ -0,0 +1,283 @@ +package dixhttp + +import ( + "encoding/json" + "math" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/pubgo/dix/v2/dixinternal" +) + +type runtimeStatsDep struct{} +type basePathTypeDep interface { + Name() string +} + +type basePathTypeDepImpl struct{} + +func (basePathTypeDepImpl) Name() string { + return "dep" +} + +func TestHandleRuntimeStats(t *testing.T) { + di := dixinternal.New() + di.Provide(func() *runtimeStatsDep { return &runtimeStatsDep{} }) + + if err := di.TryInject(func(*runtimeStatsDep) {}); err != nil { + t.Fatalf("failed to initialize dependency: %v", err) + } + + server := NewServer(di) + req := httptest.NewRequest(http.MethodGet, "/api/runtime-stats?limit=1", nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var stats []dixinternal.ProviderRuntimeStats + if err := json.Unmarshal(rr.Body.Bytes(), &stats); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(stats) == 0 { + t.Fatal("expected at least one runtime stat record") + } + + if len(stats) > 1 { + t.Fatalf("expected response limited to 1 item, got %d", len(stats)) + } +} + +func TestHandleRuntimeStatsIncludeUninitializedProviders(t *testing.T) { + type depA struct{} + type depB struct{} + + di := dixinternal.New() + di.Provide(func() *depA { return &depA{} }) + di.Provide(func() *depB { return &depB{} }) + + if err := di.TryInject(func(*depA) {}); err != nil { + t.Fatalf("failed to initialize depA: %v", err) + } + + server := NewServer(di) + req := httptest.NewRequest(http.MethodGet, "/api/runtime-stats", nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var stats []dixinternal.ProviderRuntimeStats + if err := json.Unmarshal(rr.Body.Bytes(), &stats); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(stats) < 2 { + t.Fatalf("expected at least 2 providers in runtime stats, got %d", len(stats)) + } +} + +func TestHandleErrors(t *testing.T) { + type missingDep struct{} + + di := dixinternal.New() + err := di.TryInject(func(*missingDep) {}) + if err == nil { + t.Fatal("expected TryInject to fail for missing dependency") + } + + server := NewServer(di) + req := httptest.NewRequest(http.MethodGet, "/api/errors?limit=1", nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var events []dixinternal.RecentError + if err := json.Unmarshal(rr.Body.Bytes(), &events); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if len(events) != 1 { + t.Fatalf("expected one error event with limit=1, got %d", len(events)) + } + + if events[0].Operation == "" || events[0].Message == "" { + t.Fatalf("expected operation and message in error event, got %+v", events[0]) + } + + if events[0].ErrorType == "" { + t.Fatalf("expected error_type in error event, got %+v", events[0]) + } + + if events[0].Hint == "" { + t.Fatalf("expected hint in error event, got %+v", events[0]) + } +} + +func TestHandleDiagnosticsWhenEnvNotConfigured(t *testing.T) { + t.Setenv("DIX_DIAG_FILE", "") + + server := NewServer(dixinternal.New()) + req := httptest.NewRequest(http.MethodGet, "/api/diagnostics", nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var resp map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + enabled, _ := resp["enabled"].(bool) + if enabled { + t.Fatalf("expected enabled=false when DIX_DIAG_FILE is empty, got %+v", resp) + } +} + +func TestHandleDiagnosticsReadsFileRecords(t *testing.T) { + di := dixinternal.New() + + diagPath := filepath.Join(t.TempDir(), "diag.jsonl") + content := "" + + `{"record_id":1,"kind":"trace","event":"inject.start","occurred_at_unix_nano":100}` + "\n" + + `{"record_id":2,"kind":"error","event":"","occurred_at_unix_nano":200,"payload":{"message":"boom"}}` + "\n" + + if err := os.WriteFile(diagPath, []byte(content), 0o644); err != nil { + t.Fatalf("failed to write diagnostic file: %v", err) + } + + t.Setenv("DIX_DIAG_FILE", diagPath) + + server := NewServer(di) + req := httptest.NewRequest(http.MethodGet, "/api/diagnostics?kind=trace&limit=10", nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var resp struct { + Enabled bool `json:"enabled"` + Path string `json:"path"` + Total int `json:"total"` + Records []dixinternal.DiagFileRecord `json:"records"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + + if !resp.Enabled { + t.Fatalf("expected enabled=true, got %+v", resp) + } + if resp.Path != diagPath { + t.Fatalf("expected path %s, got %s", diagPath, resp.Path) + } + if resp.Total != 1 { + t.Fatalf("expected total filtered records 1, got %d", resp.Total) + } + if len(resp.Records) != 1 || resp.Records[0].Kind != "trace" { + t.Fatalf("expected single trace record, got %+v", resp.Records) + } +} + +func TestHandleDetailsRoutesWithBasePath(t *testing.T) { + di := dixinternal.New() + di.Provide(func() basePathTypeDep { return basePathTypeDepImpl{} }) + + if err := di.TryInject(func(basePathTypeDep) {}); err != nil { + t.Fatalf("failed to initialize basePathTypeDep: %v", err) + } + + providerDetails := di.GetProviderDetails() + if len(providerDetails) == 0 { + t.Fatal("expected provider details") + } + + targetType := "" + targetPkg := "" + for _, d := range providerDetails { + if d.FunctionName != "" && d.OutputType != "" { + targetType = d.OutputType + targetPkg = extractPackage(d.OutputType) + if targetPkg != "" { + break + } + } + } + + if targetType == "" || targetPkg == "" { + t.Fatalf("failed to locate provider detail with valid type/package: %+v", providerDetails) + } + + server := NewServerWithOptions(di, WithBasePath("/dix")) + + t.Run("package details", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/dix/api/package/"+targetPkg, nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var resp PackageDetailsData + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode package response: %v", err) + } + + if resp.Package != targetPkg { + t.Fatalf("expected package %q, got %q", targetPkg, resp.Package) + } + }) + + t.Run("type details", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/dix/api/type/"+targetType+"?depth=1", nil) + rr := httptest.NewRecorder() + + server.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("expected status %d, got %d", http.StatusOK, rr.Code) + } + + var resp TypeDetailsData + if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to decode type response: %v", err) + } + + if resp.RootType != targetType { + t.Fatalf("expected root type %q, got %q", targetType, resp.RootType) + } + }) +} + +func TestWriteJSONEncodeFailureReturns500(t *testing.T) { + rr := httptest.NewRecorder() + + writeJSON(rr, map[string]float64{"nan": math.NaN()}) + + if rr.Code != http.StatusInternalServerError { + t.Fatalf("expected status %d, got %d", http.StatusInternalServerError, rr.Code) + } +} diff --git a/dixhttp/template.html b/dixhttp/template.html index 6a43b10..ecf4a4a 100644 --- a/dixhttp/template.html +++ b/dixhttp/template.html @@ -1,5 +1,6 @@ + @@ -9,7 +10,10 @@