Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(router): template expressions for access logs #1545

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from

Conversation

SkArchon
Copy link

@SkArchon SkArchon commented Jan 29, 2025

Motivation and Context

The goal is to extend the router to allow defining custom access log attributes by template expressions.

In order to do this the PR adds an "expression" field which can be set to process any log messages based on these exprlang expressions

However on top of this, the PR also goes ahead and adds the request error to the expressionContext.

This means that the user could now use expressions for access logs

access_logs:
  enabled: true
  router:
    fields:
    - key: "testthere"
      value_from:
        expression: "request.error ?? request.header.Get('X-WG-TRACE')"
  subgraphs:
    enabled: true
    fields:
      - key: "my-header"
        value_from:
          expression: "request.header.Get('X-WG-TRACE') == 'true' ? 'aa1' : 'aa2'"

In Progress / Remaining Work atm
Adding more of an "integration test" (right now I've written a unit test) for testing the expressions
docs

Checklist

  • I have discussed my proposed changes in an issue and have received approval to proceed.
  • I have followed the coding standards of the project.
  • Tests or benchmarks have been added or updated.
  • Documentation has been updated on https://github.com/wundergraph/cosmo-docs.
  • I have read the Contributors Guide.

@SkArchon SkArchon changed the title feat(router): [WIP] template expressions for access logs feat(router): template expressions for access logs Jan 29, 2025
@SkArchon
Copy link
Author

SkArchon commented Jan 29, 2025

A note on adding request.body The initial example given to me was

request.error ?? request.body

However, it seems like the body is not part of the expression context, when I was looking at adding it to the context I noticed it was of type io.Reader (readerCloser exact IIRC), which means that if the body was already read it is likely to be empty (of we will empty it by reading the io.Reader) + we would need to write code (I am assuming here) to output this to the log also.

As such, because of this + because the core specification was about adding the "error" to the context and allowing template expressions, I did not focus on this, and would like to discuss this more before continuing on deciding to add body.

// and as the method receivers are all pointers (*reportError)
// the Error() function wont get called unless its of type *reportError
switch result := val.(type) {
case reportError:
Copy link
Author

Choose a reason for hiding this comment

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

As the comment mentions, depending on the expression exprlang will return reportError instead of the pointer version *reportError, which means that Error() wont end up being called

Copy link
Contributor

Choose a reason for hiding this comment

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

I can't follow. reportError is an internal error from our router and has nothing to do with expr. Could you eloborate?

Copy link
Author

Choose a reason for hiding this comment

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

Let's say the expression in the yml is expression: "request.error"

exprlang will evaluate the expression to *core.reportError
image

Which works

However if we do the expression expression: "request.error ?? request.url", exprlang will evaluate the expression to core.reportError (not a pointer)

image

The problem is that because the method uses a pointer receiver

func (e *reportError) Error() string {

the second expression does not call Error, thus will print out a memory address instead. Thus I cast it to *core.reportError

This could happen to any other similar errors (could happen both ways, depending on if the receiver is a pointer or not), however I only noticed this happening with core.reportError for now

Copy link
Contributor

Choose a reason for hiding this comment

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

Now I understand. You were referring to the returned error of the expression. Why do you believe it is important toi align this. The value should not matter because we will pass it straight to the logger which accepts any.

Copy link
Author

Choose a reason for hiding this comment

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

Even though the logger accepts any, the final log message ends up becoming a memory location. This becomes a problem when a customer expects the error message to be printed.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd expect that whatever the user returns in an expression that zap is able to log it and that the pointer is dereferenced automatically before printing.

Copy link
Author

Choose a reason for hiding this comment

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

From my testing so far no, but let me double check a bit more and get back to you.

@@ -0,0 +1,32 @@
package requestlogger
Copy link
Author

Choose a reason for hiding this comment

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

The other function that tests the logger lies in this package requestlogger, but creating of the requestContext which is needed for evaluating expressions is in the core package, and is not exported.

Which means if I was to write my test cases the same way here in the subgraph_logger_test.go file, I will need to export the buildRequestContext function (and related struct), which seemed like a bad idea for this one test.

But it also made me wonder if that code should be in a separate (maybe "inner-core" package, or this package merged with core since its quite small?). I ended up writing a unit test directly access_log_field_handler_test in the core package.

@SkArchon SkArchon marked this pull request as ready for review January 29, 2025 19:51
@SkArchon SkArchon marked this pull request as draft January 29, 2025 19:54
router/pkg/config/config.schema.json Outdated Show resolved Hide resolved
router/pkg/config/config.schema.json Outdated Show resolved Hide resolved
// and as the method receivers are all pointers (*reportError)
// the Error() function wont get called unless its of type *reportError
switch result := val.(type) {
case reportError:
Copy link
Contributor

Choose a reason for hiding this comment

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

I can't follow. reportError is an internal error from our router and has nothing to do with expr. Could you eloborate?

router/core/access_log_field_handler_test.go Show resolved Hide resolved
@@ -217,7 +217,7 @@ func (h *PreHandler) Handler(next http.Handler) http.Handler {

executionOptions, traceOptions, err := h.parseRequestOptions(r, clientInfo, requestLogger)
if err != nil {
requestContext.error = err
AssignErrors(requestContext, err)
Copy link
Contributor

Choose a reason for hiding this comment

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

Instead of writing a utility function to assign the error, you could implement ResolveAnyExpression similar as withResolveStringExpression on the requestContext. This would follow the pattern and the error can be set right before we pass the expressionContext to the template engine.

Copy link
Author

@SkArchon SkArchon Jan 30, 2025

Choose a reason for hiding this comment

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

Agree on the ResolveAnyExpression, however I want to clarify about assigning errors in the ResolveAnyExpression function.

My assumption was that whenever an error occurred that the error should be available as part of the expressionContext. So if anyone did
reqContext.expressionContext.errror at any time they wont get null (and would be the same requestContext.error).

With this change they would get null, which could be a mental model mismatch,

But from what you are saying is that they should not access the expressionContext this way so its ok, instead they should access it via any Resolve*Expression method, for which we will set the error to the expressionContext before the resolving step?

Copy link
Contributor

Choose a reason for hiding this comment

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

Correct. Thanks for the explanation.

router/internal/requestlogger/access_log_expression.go Outdated Show resolved Hide resolved
@StarpTech
Copy link
Contributor

 note on adding request.body The initial example given to me was

request.error ?? request.body

That's correct. I'd suggest to finish it up to the point we discussed it. We can add this later. The customer wanted to log the GraphQL operation in case of an error.

@SkArchon SkArchon force-pushed the milindadias/access-logging-using-custom-expressions branch from b0e8ed0 to c6b6df6 Compare January 30, 2025 18:39
func (c *requestContext) ResolveStringExpression(expression *vm.Program) (string, error) {
c.setErrorContext()
Copy link
Author

Choose a reason for hiding this comment

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

We now set the expressionContext error when accessing through these functions

router/core/context.go Outdated Show resolved Hide resolved
currContextRequest := c.expressionContext.Request
if err != nil {
// We don't really want to do a deep clone since we only know that error only can get modified
copyContext := expr.Context{
Copy link
Contributor

@StarpTech StarpTech Jan 30, 2025

Choose a reason for hiding this comment

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

Why do we need to create a new expr.Context ? it's not clear from your comment.

currContextRequest := c.expressionContext.Request
if err != nil {
// We don't really want to do a deep clone since we only know that error only can get modified
copyContext := expr.Context{
Copy link
Author

Choose a reason for hiding this comment

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

I ran into a problem where when an error occurs in the subgraph, it's not assigned to the requestContext.error at the point of being called.

Thus any expressions like request.error?? 'no-error' returned 'no-error'. Observing the same pattern that was used in the predefined context fields for request_error which passes an error directly, I used a similar approach / workaround. Creating a copy of the expressionContext (non deep) and assigning it to resolve the expression.

@@ -316,3 +316,14 @@ func writeOperationError(r *http.Request, w http.ResponseWriter, requestLogger *
writeRequestErrors(r, w, http.StatusInternalServerError, graphqlerrors.RequestErrorsFromError(errInternalServer), requestLogger)
}
}

type ExprWrapError struct {
Copy link
Author

Choose a reason for hiding this comment

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

This is to circumvent this problem described here:
#1545 (comment)

While testing further I noticed that there can be other error types, so it was safer to wrap the error in a type we can control, so we have a deterministic error type outputted from the exprlang expression.

(I didn't come across anything atm, but if there is a better existing type to use, we can reuse that)

if c.error == nil {
return
}
c.expressionContext.Request.Error = &ExprWrapError{c.error}
Copy link
Author

Choose a reason for hiding this comment

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

logger *zap.Logger,
attributes []config.CustomAttribute,
exprAttributes []requestlogger.ExpressionAttribute,
passedErr error,
Copy link
Author

Choose a reason for hiding this comment

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

The previous type was "any" because it took inputs from panics and errors, I went ahead and split it into two as we want to maintain the error type in the ExpressionContext, but if we are ok to make it even more generic just for the panic, we can merge the fields again.

@SkArchon SkArchon requested a review from StarpTech January 30, 2025 18:50
continue
}

err := exprlocal.ValidateAnyExpression(exprString)
Copy link
Author

Choose a reason for hiding this comment

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

Validate against nil and a few other types, we can add / remove types later as necessary

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants