diff --git a/docs/content/configuration.md b/docs/content/configuration.md index 8fa173cd7e..620315074d 100644 --- a/docs/content/configuration.md +++ b/docs/content/configuration.md @@ -723,21 +723,22 @@ included in the actual bundle gzipped tarball. ## Decision Logs -| Field | Type | Required | Description | -| --- | --- | --- | --- | -| `decision_logs.service` | `string` | No | Name of the service to use to contact remote server. If no `plugin` is specified, and `console` logging is disabled, this will default to the first `service` name defined in the Services configuration. | -| `decision_logs.partition_name` | `string` | No | Deprecated: Use `resource` instead. Path segment to include in status updates. | -| `decision_logs.resource` | `string` | No (default: `/logs`) | Full path to use for sending decision logs to a remote server. | -| `decision_logs.reporting.buffer_size_limit_bytes` | `int64` | No | Decision log buffer size limit in bytes. OPA will drop old events from the log if this limit is exceeded. By default, no limit is set. Only one of `buffer_size_limit_bytes`, `max_decisions_per_second` may be set. | +| Field | Type | Required | Description | +|----------------------------------------------------| -- | --- | --- | +| `decision_logs.service` | `string` | No | Name of the service to use to contact remote server. If no `plugin` is specified, and `console` logging is disabled, this will default to the first `service` name defined in the Services configuration. | +| `decision_logs.partition_name` | `string` | No | Deprecated: Use `resource` instead. Path segment to include in status updates. | +| `decision_logs.resource` | `string` | No (default: `/logs`) | Full path to use for sending decision logs to a remote server. | +| `decision_logs.reporting.buffer_size_limit_bytes` | `int64` | No | Decision log buffer size limit in bytes. OPA will drop old events from the log if this limit is exceeded. By default, no limit is set. Only one of `buffer_size_limit_bytes`, `max_decisions_per_second` may be set. | | `decision_logs.reporting.max_decisions_per_second` | `float64` | No | Maximum number of decision log events to buffer per second. OPA will drop events if the rate limit is exceeded. Only one of `buffer_size_limit_bytes`, `max_decisions_per_second` may be set. | -| `decision_logs.reporting.upload_size_limit_bytes` | `int64` | No (default: `32768`) | Decision log upload size limit in bytes. OPA will chunk uploads to cap message body to this limit. | -| `decision_logs.reporting.min_delay_seconds` | `int64` | No (default: `300`) | Minimum amount of time to wait between uploads. | -| `decision_logs.reporting.max_delay_seconds` | `int64` | No (default: `600`) | Maximum amount of time to wait between uploads. | -| `decision_logs.reporting.trigger` | `string` | No (default: `periodic`) | Controls how decision logs are reported to the remote server. Allowed values are `periodic` and `manual` (`manual` triggers are only possible when using OPA as a Go package). | -| `decision_logs.mask_decision` | `string` | No (default: `/system/log/mask`) | Set path of masking decision. | -| `decision_logs.drop_decision` | `string` | No (default: `/system/log/drop`) | Set path of drop decision. | -| `decision_logs.plugin` | `string` | No | Use the named plugin for decision logging. If this field exists, the other configuration fields are not required. | -| `decision_logs.console` | `boolean` | No (default: `false`) | Log the decisions locally to the console. When enabled alongside a remote decision logging API the `service` must be configured, the default `service` selection will be disabled. | +| `decision_logs.reporting.upload_size_limit_bytes` | `int64` | No (default: `32768`) | Decision log upload size limit in bytes. OPA will chunk uploads to cap message body to this limit. | +| `decision_logs.reporting.min_delay_seconds` | `int64` | No (default: `300`) | Minimum amount of time to wait between uploads. | +| `decision_logs.reporting.max_delay_seconds` | `int64` | No (default: `600`) | Maximum amount of time to wait between uploads. | +| `decision_logs.reporting.trigger` | `string` | No (default: `periodic`) | Controls how decision logs are reported to the remote server. Allowed values are `periodic` and `manual` (`manual` triggers are only possible when using OPA as a Go package). | +| `decision_logs.mask_decision` | `string` | No (default: `/system/log/mask`) | Set path of masking decision. | +| `decision_logs.drop_decision` | `string` | No (default: `/system/log/drop`) | Set path of drop decision. | +| `decision_logs.plugin` | `string` | No | Use the named plugin for decision logging. If this field exists, the other configuration fields are not required. | +| `decision_logs.console` | `boolean` | No (default: `false`) | Log the decisions locally to the console. When enabled alongside a remote decision logging API the `service` must be configured, the default `service` selection will be disabled. | +| `decision_logs.request_context.http.headers` | `array` | No | List of HTTP headers to include in the decision log. OPA will include the values for these headers in the decision log if they exist in the incoming HTTP request. | ## Discovery diff --git a/docs/content/management-decision-logs.md b/docs/content/management-decision-logs.md index 0f200b88d0..3deda27c5f 100644 --- a/docs/content/management-decision-logs.md +++ b/docs/content/management-decision-logs.md @@ -60,25 +60,26 @@ represents a policy decision returned by OPA. Decision log updates contain the following fields: -| Field | Type | Description | -|---------------------------| --- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `[_].labels` | `object` | Set of key-value pairs that uniquely identify the OPA instance. | -| `[_].decision_id` | `string` | Unique identifier generated for each decision for traceability. | -| `[_].trace_id` | `string` | Unique identifier of a trace generated for each incoming request for traceability. This is a hex string representation compliant with the W3C trace-context specification. See more at https://www.w3.org/TR/trace-context/#trace-id. | -| `[_].span_id` | `string` | Unique identifier of a span in a trace to assist traceability. This is a hex string representation compliant with the W3C trace-context specification. See more at https://www.w3.org/TR/trace-context/#parent-id. | -| `[_].bundles` | `object` | Set of key-value pairs describing the bundles which contained policy used to produce the decision. | -| `[_].bundles[_].revision` | `string` | Revision of the bundle at the time of evaluation. | -| `[_].path` | `string` | Hierarchical policy decision path, e.g., `/http/example/authz/allow`. Receivers should tolerate slash-prefixed paths. | -| `[_].query` | `string` | Ad-hoc Rego query received by Query API. | -| `[_].input` | `any` | Input data provided in the policy query. | -| `[_].result` | `any` | Policy decision returned to the client, e.g., `true` or `false`. | -| `[_].requested_by` | `string` | Identifier for client that executed policy query, e.g., the client address. | -| `[_].timestamp` | `string` | RFC3999 timestamp of policy decision. | -| `[_].metrics` | `object` | Key-value pairs of [performance metrics](../rest-api#performance-metrics). | -| `[_].erased` | `array[string]` | Set of JSON Pointers specifying fields in the event that were erased. | -| `[_].masked` | `array[string]` | Set of JSON Pointers specifying fields in the event that were masked. | -| `[_].nd_builtin_cache` | `object` | Key-value pairs of non-deterministic builtin names, paired with objects specifying the input/output mappings for each unique invocation of that builtin during policy evaluation. Intended for use in debugging and decision replay. Receivers will need to decode the JSON using Rego's JSON decoders. | -| `[_].req_id` | `number` | Incremental request identifier, and unique only to the OPA instance, for the request that started the policy query. The attribute value is the same as the value present in others logs (request, response, and print) and could be used to correlate them all. This attribute will be included just when OPA runtime is initialized in server mode and the log level is equal to or greater than info. | +| Field | Type | Description | +|------------------------------------| -- |--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `[_].labels` | `object` | Set of key-value pairs that uniquely identify the OPA instance. | +| `[_].decision_id` | `string` | Unique identifier generated for each decision for traceability. | +| `[_].trace_id` | `string` | Unique identifier of a trace generated for each incoming request for traceability. This is a hex string representation compliant with the W3C trace-context specification. See more at https://www.w3.org/TR/trace-context/#trace-id. | +| `[_].span_id` | `string` | Unique identifier of a span in a trace to assist traceability. This is a hex string representation compliant with the W3C trace-context specification. See more at https://www.w3.org/TR/trace-context/#parent-id. | +| `[_].bundles` | `object` | Set of key-value pairs describing the bundles which contained policy used to produce the decision. | +| `[_].bundles[_].revision` | `string` | Revision of the bundle at the time of evaluation. | +| `[_].path` | `string` | Hierarchical policy decision path, e.g., `/http/example/authz/allow`. Receivers should tolerate slash-prefixed paths. | +| `[_].query` | `string` | Ad-hoc Rego query received by Query API. | +| `[_].input` | `any` | Input data provided in the policy query. | +| `[_].result` | `any` | Policy decision returned to the client, e.g., `true` or `false`. | +| `[_].requested_by` | `string` | Identifier for client that executed policy query, e.g., the client address. | +| `[_].request_context.http.headers` | `object` | Set of key-value pairs describing HTTP headers and their corresponding values. The header keys in this object are specified by the user as part of the decision log configuration. The values in this object represent a list of values associated with the given header key. | +| `[_].timestamp` | `string` | RFC3999 timestamp of policy decision. | +| `[_].metrics` | `object` | Key-value pairs of [performance metrics](../rest-api#performance-metrics). | +| `[_].erased` | `array[string]` | Set of JSON Pointers specifying fields in the event that were erased. | +| `[_].masked` | `array[string]` | Set of JSON Pointers specifying fields in the event that were masked. | +| `[_].nd_builtin_cache` | `object` | Key-value pairs of non-deterministic builtin names, paired with objects specifying the input/output mappings for each unique invocation of that builtin during policy evaluation. Intended for use in debugging and decision replay. Receivers will need to decode the JSON using Rego's JSON decoders. | +| `[_].req_id` | `number` | Incremental request identifier, and unique only to the OPA instance, for the request that started the policy query. The attribute value is the same as the value present in others logs (request, response, and print) and could be used to correlate them all. This attribute will be included just when OPA runtime is initialized in server mode and the log level is equal to or greater than info. | If the decision log was successfully uploaded to the remote service, it should respond with an HTTP 2xx status. If the service responds with a non-2xx status, OPA will requeue the last chunk containing decision log events and upload it diff --git a/logging/logging.go b/logging/logging.go index 22eb16e43e..83e2bcbca5 100644 --- a/logging/logging.go +++ b/logging/logging.go @@ -3,6 +3,7 @@ package logging import ( "context" "io" + "net/http" "github.com/sirupsen/logrus" ) @@ -210,10 +211,15 @@ const reqCtxKey = requestContextKey("request-context-key") // RequestContext represents the request context used to store data // related to the request that could be used on logs. type RequestContext struct { - ClientAddr string - ReqID uint64 - ReqMethod string - ReqPath string + ClientAddr string + ReqID uint64 + ReqMethod string + ReqPath string + HTTPRequestContext HTTPRequestContext +} + +type HTTPRequestContext struct { + Header http.Header } // Fields adapts the RequestContext fields to logrus.Fields. diff --git a/plugins/logs/plugin.go b/plugins/logs/plugin.go index 4f791a3b19..5a58f827a0 100644 --- a/plugins/logs/plugin.go +++ b/plugins/logs/plugin.go @@ -64,6 +64,7 @@ type EventV1 struct { Timestamp time.Time `json:"timestamp"` Metrics map[string]interface{} `json:"metrics,omitempty"` RequestID uint64 `json:"req_id,omitempty"` + RequestContext *RequestContext `json:"request_context,omitempty"` inputAST ast.Value } @@ -73,6 +74,14 @@ type BundleInfoV1 struct { Revision string `json:"revision,omitempty"` } +type RequestContext struct { + HTTPRequest *HTTPRequestContext `json:"http,omitempty"` +} + +type HTTPRequestContext struct { + Headers map[string][]string `json:"headers,omitempty"` +} + // AST returns the BundleInfoV1 as an AST value func (b *BundleInfoV1) AST() ast.Value { result := ast.NewObject() @@ -262,17 +271,26 @@ type ReportingConfig struct { Trigger *plugins.TriggerMode `json:"trigger,omitempty"` // trigger mode } +type RequestContextConfig struct { + HTTPRequest *HTTPRequestContextConfig `json:"http,omitempty"` +} + +type HTTPRequestContextConfig struct { + Headers []string `json:"headers,omitempty"` +} + // Config represents the plugin configuration. type Config struct { - Plugin *string `json:"plugin"` - Service string `json:"service"` - PartitionName string `json:"partition_name,omitempty"` - Reporting ReportingConfig `json:"reporting"` - MaskDecision *string `json:"mask_decision"` - DropDecision *string `json:"drop_decision"` - ConsoleLogs bool `json:"console"` - Resource *string `json:"resource"` - NDBuiltinCache bool `json:"nd_builtin_cache,omitempty"` + Plugin *string `json:"plugin"` + Service string `json:"service"` + PartitionName string `json:"partition_name,omitempty"` + Reporting ReportingConfig `json:"reporting"` + RequestContext RequestContextConfig `json:"request_context"` + MaskDecision *string `json:"mask_decision"` + DropDecision *string `json:"drop_decision"` + ConsoleLogs bool `json:"console"` + Resource *string `json:"resource"` + NDBuiltinCache bool `json:"nd_builtin_cache,omitempty"` maskDecisionRef ast.Ref dropDecisionRef ast.Ref } @@ -620,6 +638,22 @@ func (p *Plugin) Log(ctx context.Context, decision *server.Info) error { inputAST: decision.InputAST, } + headers := map[string][]string{} + rctx := p.config.RequestContext + + if rctx.HTTPRequest != nil && len(rctx.HTTPRequest.Headers) > 0 && decision.HTTPRequestContext.Header != nil { + for _, h := range rctx.HTTPRequest.Headers { + values := decision.HTTPRequestContext.Header.Values(h) + if len(values) > 0 { + headers[h] = decision.HTTPRequestContext.Header.Values(h) + } + } + } + + if len(headers) > 0 { + event.RequestContext = &RequestContext{HTTPRequest: &HTTPRequestContext{Headers: headers}} + } + input, err := event.AST() if err != nil { return err diff --git a/plugins/logs/plugin_test.go b/plugins/logs/plugin_test.go index 4c8aa3c855..e5d8a9bf8e 100644 --- a/plugins/logs/plugin_test.go +++ b/plugins/logs/plugin_test.go @@ -22,6 +22,7 @@ import ( "time" "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/logging" "github.com/open-policy-agent/opa/logging/test" "github.com/open-policy-agent/opa/metrics" "github.com/open-policy-agent/opa/plugins" @@ -154,6 +155,117 @@ func TestPluginCustomBackendAndHTTPServiceAndConsole(t *testing.T) { } } +func TestPluginRequestContext(t *testing.T) { + ctx := context.Background() + manager, _ := plugins.New(nil, "test-instance-id", inmem.New()) + + backend := &testPlugin{} + manager.Register("test_plugin", backend) + + h1 := http.Header{} + h1.Set("foo", "bar") + + h2 := http.Header{} + h2.Set("foo", "bar") + h2.Add("foo2", "bar") + h2.Add("foo2", "bar2") + + cases := []struct { + note string + config []byte + decisionInfo *server.Info + expected *RequestContext + }{ + { + note: "no request context config - no request context in decision info", + config: []byte(`{"plugin": "test_plugin"}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}}, + expected: nil, + }, + { + note: "request context in config (single header) - no request context in decision info", + config: []byte(`{"plugin": "test_plugin", "request_context": {"http": {"headers": ["foo"]}}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}}, + expected: nil, + }, + { + note: "request context in config (single header) - request context in decision info (no header map)", + config: []byte(`{"plugin": "test_plugin", "request_context": {"http": {"headers": ["foo"]}}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: nil}}, + expected: nil, + }, + { + note: "request context in config (single header) - request context in decision info (with header map)", + config: []byte(`{"plugin": "test_plugin", "request_context": {"http": {"headers": ["foo"]}}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: h1}}, + expected: &RequestContext{HTTPRequest: &HTTPRequestContext{Headers: map[string][]string{"foo": []string{"bar"}}}}, + }, + { + note: "request context in config (multiple headers) - request context in decision info (with header map partial)", + config: []byte(`{"plugin": "test_plugin", "request_context": {"http": {"headers": ["foo", "foo2"]}}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: h1}}, + expected: &RequestContext{HTTPRequest: &HTTPRequestContext{Headers: map[string][]string{"foo": []string{"bar"}}}}, + }, + { + note: "request context in config (multiple headers) - request context in decision info (with header map full)", + config: []byte(`{"plugin": "test_plugin", "request_context": {"http": {"headers": ["foo", "foo2"]}}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: h2}}, + expected: &RequestContext{HTTPRequest: &HTTPRequestContext{Headers: map[string][]string{"foo": []string{"bar"}, "foo2": []string{"bar", "bar2"}}}}, + }, + { + note: "request context in config (single header) - request context in decision info (with header map full)", + config: []byte(`{"plugin": "test_plugin", "request_context": {"http": {"headers": ["foo"]}}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: h2}}, + expected: &RequestContext{HTTPRequest: &HTTPRequestContext{Headers: map[string][]string{"foo": []string{"bar"}}}}, + }, + { + note: "no request context in config - request context in decision info (with header map)", + config: []byte(`{"plugin": "test_plugin"}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: h1}}, + expected: nil, + }, + { + note: "request context in config (no http) - request context in decision info (with header map)", + config: []byte(`{"plugin": "test_plugin", "request_context": {}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: h1}}, + expected: nil, + }, + { + note: "request context in config (no headers) - request context in decision info (with header map)", + config: []byte(`{"plugin": "test_plugin", "request_context": {"http": {}}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: h1}}, + expected: nil, + }, + { + note: "request context in config (empty headers list) - request context in decision info (with header map)", + config: []byte(`{"plugin": "test_plugin", "request_context": {"http": {"headers": []}}}`), + decisionInfo: &server.Info{Bundles: map[string]server.BundleInfo{"b1": {Revision: "A"}}, HTTPRequestContext: logging.HTTPRequestContext{Header: h1}}, + expected: nil, + }, + } + + for i, tc := range cases { + t.Run(tc.note, func(t *testing.T) { + + config, err := ParseConfig(tc.config, nil, []string{"test_plugin"}) + if err != nil { + t.Fatal(err) + } + + plugin := New(config, manager) + plugin.Log(ctx, tc.decisionInfo) + + if len(backend.events) == 0 { + t.Fatal("expected at least one event") + } + + if !reflect.DeepEqual(backend.events[i].RequestContext, tc.expected) { + t.Fatalf("unexpected request context, want %+v but got %+v", tc.expected, backend.events[0].RequestContext) + } + }) + } +} + func TestPluginSingleBundle(t *testing.T) { ctx := context.Background() manager, _ := plugins.New(nil, "test-instance-id", inmem.New()) diff --git a/runtime/logging.go b/runtime/logging.go index 3c84d6efb9..a7a51122d4 100644 --- a/runtime/logging.go +++ b/runtime/logging.go @@ -60,6 +60,7 @@ func (h *LoggingHandler) loggingEnabled(level logging.Level) bool { func (h *LoggingHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { var rctx logging.RequestContext rctx.ReqID = atomic.AddUint64(&h.requestID, uint64(1)) + rctx.HTTPRequestContext = logging.HTTPRequestContext{Header: r.Header.Clone()} recorder := newRecorder(h.logger, w, r, rctx.ReqID, h.loggingEnabled(logging.Debug)) t0 := time.Now() diff --git a/server/buffer.go b/server/buffer.go index 634cdd006e..412abe1c44 100644 --- a/server/buffer.go +++ b/server/buffer.go @@ -8,6 +8,7 @@ import ( "time" "github.com/open-policy-agent/opa/ast" + "github.com/open-policy-agent/opa/logging" "github.com/open-policy-agent/opa/metrics" "github.com/open-policy-agent/opa/storage" "github.com/open-policy-agent/opa/topdown" @@ -15,25 +16,26 @@ import ( // Info contains information describing a policy decision. type Info struct { - Txn storage.Transaction - Revision string // Deprecated: Use `Bundles` instead - Bundles map[string]BundleInfo - DecisionID string - TraceID string - SpanID string - RemoteAddr string - Query string - Path string - Timestamp time.Time - Input *interface{} - InputAST ast.Value - Results *interface{} - MappedResults *interface{} - NDBuiltinCache *interface{} - Error error - Metrics metrics.Metrics - Trace []*topdown.Event - RequestID uint64 + Txn storage.Transaction + Revision string // Deprecated: Use `Bundles` instead + Bundles map[string]BundleInfo + DecisionID string + TraceID string + SpanID string + RemoteAddr string + HTTPRequestContext logging.HTTPRequestContext + Query string + Path string + Timestamp time.Time + Input *interface{} + InputAST ast.Value + Results *interface{} + MappedResults *interface{} + NDBuiltinCache *interface{} + Error error + Metrics metrics.Metrics + Trace []*topdown.Event + RequestID uint64 } // BundleInfo contains information describing a bundle. diff --git a/server/server.go b/server/server.go index 740695592b..4e0e7930f9 100644 --- a/server/server.go +++ b/server/server.go @@ -2958,20 +2958,21 @@ func (l decisionLogger) Log(ctx context.Context, txn storage.Transaction, path s decisionID, _ := logging.DecisionIDFromContext(ctx) info := &Info{ - Txn: txn, - Revision: l.revision, - Bundles: bundles, - Timestamp: time.Now().UTC(), - DecisionID: decisionID, - RemoteAddr: rctx.ClientAddr, - Path: path, - Query: query, - Input: goInput, - InputAST: astInput, - Results: goResults, - Error: err, - Metrics: m, - RequestID: rctx.ReqID, + Txn: txn, + Revision: l.revision, + Bundles: bundles, + Timestamp: time.Now().UTC(), + DecisionID: decisionID, + RemoteAddr: rctx.ClientAddr, + HTTPRequestContext: rctx.HTTPRequestContext, + Path: path, + Query: query, + Input: goInput, + InputAST: astInput, + Results: goResults, + Error: err, + Metrics: m, + RequestID: rctx.ReqID, } if ndbCache != nil { diff --git a/server/server_test.go b/server/server_test.go index d9f2577a6e..1e3d3b7213 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3491,6 +3491,51 @@ func TestDecisionIDs(t *testing.T) { } } +func TestDecisionLoggingWithHTTPRequestContext(t *testing.T) { + f := newFixture(t) + + decisions := []*Info{} + + var nextID int + + f.server = f.server.WithDecisionIDFactory(func() string { + nextID++ + return fmt.Sprint(nextID) + }).WithDecisionLoggerWithErr(func(_ context.Context, info *Info) error { + decisions = append(decisions, info) + return nil + }) + + req := newReqV1("POST", "/data/nonexistent", `{"input": {"foo": 1}}`) + req.Header.Set("foo", "bar") + req.Header.Set("foo2", "bar2") + req.Header.Add("foo2", "bar3") + + var rctx logging.RequestContext + rctx.HTTPRequestContext = logging.HTTPRequestContext{Header: req.Header.Clone()} + + req = req.WithContext(logging.NewContext(req.Context(), &rctx)) + + if err := f.executeRequest(req, http.StatusOK, `{"decision_id": "1"}`); err != nil { + t.Fatal(err) + } + + if len(decisions) != 1 { + t.Fatalf("Expected exactly 1 decision but got: %d", len(decisions)) + } + + expHeaders := http.Header{} + expHeaders.Set("foo", "bar") + expHeaders.Add("foo2", "bar2") + expHeaders.Add("foo2", "bar3") + + exp := logging.HTTPRequestContext{Header: expHeaders} + + if !reflect.DeepEqual(decisions[0].HTTPRequestContext, exp) { + t.Fatalf("Expected HTTP request context %v but got: %v", exp, decisions[0].HTTPRequestContext) + } +} + func TestDecisionLogging(t *testing.T) { f := newFixture(t)