diff --git a/module/pipeline_step_http_call.go b/module/pipeline_step_http_call.go index a0325a4a..bb357085 100644 --- a/module/pipeline_step_http_call.go +++ b/module/pipeline_step_http_call.go @@ -470,6 +470,7 @@ func (s *HTTPCallStep) Execute(ctx context.Context, pc *PipelineContext) (*StepR return nil, err } + start := time.Now() resp, err := s.httpClient.Do(req) //nolint:gosec // G107: URL is user-configured if err != nil { return nil, fmt.Errorf("http_call step %q: request failed: %w", s.name, err) @@ -477,6 +478,7 @@ func (s *HTTPCallStep) Execute(ctx context.Context, pc *PipelineContext) (*StepR defer resp.Body.Close() respBody, err := io.ReadAll(resp.Body) + elapsedMS := time.Since(start).Milliseconds() if err != nil { return nil, fmt.Errorf("http_call step %q: failed to read response: %w", s.name, err) } @@ -511,6 +513,7 @@ func (s *HTTPCallStep) Execute(ctx context.Context, pc *PipelineContext) (*StepR return nil, buildErr } + retryStart := time.Now() retryResp, doErr := s.httpClient.Do(retryReq) //nolint:gosec // G107: URL is user-configured if doErr != nil { return nil, fmt.Errorf("http_call step %q: retry request failed: %w", s.name, doErr) @@ -518,11 +521,13 @@ func (s *HTTPCallStep) Execute(ctx context.Context, pc *PipelineContext) (*StepR defer retryResp.Body.Close() respBody, err = io.ReadAll(retryResp.Body) + retryElapsedMS := time.Since(retryStart).Milliseconds() if err != nil { return nil, fmt.Errorf("http_call step %q: failed to read retry response: %w", s.name, err) } output := parseHTTPResponse(retryResp, respBody) + output["elapsed_ms"] = retryElapsedMS if instanceURL := s.oauthEntry.getInstanceURL(); instanceURL != "" { output["instance_url"] = instanceURL } @@ -533,6 +538,7 @@ func (s *HTTPCallStep) Execute(ctx context.Context, pc *PipelineContext) (*StepR } output := parseHTTPResponse(resp, respBody) + output["elapsed_ms"] = elapsedMS if s.auth != nil { if instanceURL := s.oauthEntry.getInstanceURL(); instanceURL != "" { output["instance_url"] = instanceURL diff --git a/module/pipeline_step_http_call_test.go b/module/pipeline_step_http_call_test.go index 0397ab0f..ab24a722 100644 --- a/module/pipeline_step_http_call_test.go +++ b/module/pipeline_step_http_call_test.go @@ -272,6 +272,13 @@ func TestHTTPCallStep_OAuth2_Retry401(t *testing.T) { if result.Output["status_code"] != http.StatusOK { t.Errorf("expected 200 after retry, got %v", result.Output["status_code"]) } + retryElapsedMS, ok := result.Output["elapsed_ms"].(int64) + if !ok { + t.Fatalf("expected elapsed_ms to be int64 on retry path, got %T (%v)", result.Output["elapsed_ms"], result.Output["elapsed_ms"]) + } + if retryElapsedMS < 0 { + t.Errorf("expected elapsed_ms >= 0 on retry path, got %d", retryElapsedMS) + } if atomic.LoadInt32(&tokenRequests) != 2 { t.Errorf("expected 2 token requests, got %d", atomic.LoadInt32(&tokenRequests)) } @@ -1076,3 +1083,35 @@ func TestHTTPCallStep_BodyFrom_NilValue(t *testing.T) { t.Errorf("expected empty body for nil body_from, got %q", string(gotBody)) } } + +func TestHTTPCallStep_ElapsedMS(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{}`)) + })) + defer srv.Close() + + factory := NewHTTPCallStepFactory() + step, err := factory("elapsed-test", map[string]any{ + "url": srv.URL, + "method": "GET", + }, nil) + if err != nil { + t.Fatalf("factory error: %v", err) + } + step.(*HTTPCallStep).httpClient = srv.Client() + + pc := NewPipelineContext(nil, nil) + result, err := step.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("execute error: %v", err) + } + + elapsedMS, ok := result.Output["elapsed_ms"].(int64) + if !ok { + t.Fatalf("expected elapsed_ms to be int64, got %T (%v)", result.Output["elapsed_ms"], result.Output["elapsed_ms"]) + } + if elapsedMS < 0 { + t.Errorf("expected elapsed_ms >= 0, got %d", elapsedMS) + } +} diff --git a/schema/step_schema_builtins.go b/schema/step_schema_builtins.go index fd20c754..cc870ac7 100644 --- a/schema/step_schema_builtins.go +++ b/schema/step_schema_builtins.go @@ -82,9 +82,11 @@ func (r *StepSchemaRegistry) registerBuiltins() { {Key: "auth", Type: FieldTypeMap, Description: "Authentication config (type, token, client_id, client_secret, token_url for OAuth2)"}, }, Outputs: []StepOutputDef{ - {Key: "status", Type: "number", Description: "HTTP response status code"}, + {Key: "status_code", Type: "number", Description: "HTTP response status code (e.g. 200)"}, + {Key: "status", Type: "string", Description: "HTTP response status text (e.g. \"200 OK\")"}, {Key: "body", Type: "any", Description: "Response body (parsed as JSON if Content-Type is application/json)"}, {Key: "headers", Type: "map", Description: "Response headers"}, + {Key: "elapsed_ms", Type: "number", Description: "Request duration in milliseconds (wall-clock time from send to response fully read)"}, }, }) diff --git a/schema/step_schema_test.go b/schema/step_schema_test.go index 37c331fb..4e650f41 100644 --- a/schema/step_schema_test.go +++ b/schema/step_schema_test.go @@ -255,7 +255,7 @@ func TestInferStepOutputs_Fallback(t *testing.T) { for _, o := range outputs { keys[o.Key] = true } - if !keys["status"] || !keys["body"] || !keys["headers"] { + if !keys["status_code"] || !keys["body"] || !keys["headers"] || !keys["elapsed_ms"] { t.Errorf("expected static outputs for step.http_call, got %v", outputs) } }