File: context.rb

package info (click to toggle)
ruby-gitlab-labkit 0.36.1-2
  • links: PTS, VCS
  • area: main
  • in suites: experimental
  • size: 444 kB
  • sloc: ruby: 1,705; makefile: 6
file content (146 lines) | stat: -rw-r--r-- 3,205 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
# frozen_string_literal: true

require "securerandom"

require "active_support/core_ext/module/delegation"
require "active_support/core_ext/string/starts_ends_with"
require "active_support/core_ext/string/inflections"

module Labkit
  # A context can be used to provide structured information on what resources
  # GitLab is working on within a service.
  #
  # Values can be provided by passing a hash. If one of the values is a Proc
  # the proc will only be called when the value is actually needed.
  #
  # Multiple contexts can be nested, the nested context will inherit the values
  # from the closest outer one.
  # All contexts will have the same correlation id.
  #
  # Usage:
  #   Labkit::Context.with_context(user: 'username', root_namespace: -> { get_root_namespace } do |context|
  #     logger.info(context.to_h)
  #   end
  #
  class Context
    LOG_KEY = "meta"
    CORRELATION_ID_KEY = "correlation_id"
    RAW_KEYS = [CORRELATION_ID_KEY].freeze

    class << self
      def with_context(attributes = {})
        context = push(attributes)

        begin
          yield(context)
        ensure
          pop(context)
        end
      end

      def push(new_attributes = {})
        new_context = current&.merge(new_attributes) || new(new_attributes)

        contexts.push(new_context)

        new_context
      end

      def pop(context)
        contexts.pop while contexts.include?(context)
      end

      def correlation_id
        contexts.last&.correlation_id
      end

      def current
        contexts.last
      end

      def log_key(key)
        key = key.to_s
        return key if RAW_KEYS.include?(key)
        return key if key.starts_with?("#{LOG_KEY}.")

        "#{LOG_KEY}.#{key}"
      end

      private

      def contexts
        Thread.current[:labkit_contexts] ||= []
      end
    end

    def initialize(values = {})
      @data = {}

      assign_attributes(values)
    end

    def merge(new_attributes)
      new_context = self.class.new(data.dup)
      new_context.assign_attributes(new_attributes)

      new_context
    end

    def to_h
      expand_data
    end

    def correlation_id
      data[CORRELATION_ID_KEY]
    end

    def get_attribute(attribute)
      raw = call_or_value(data[log_key(attribute)])

      call_or_value(raw)
    end

    protected

    def assign_attributes(attributes)
      attributes = attributes.transform_keys(&method(:log_key))

      data.merge!(attributes)

      # Remove keys that had their values set to `nil` in the new attributes
      data.keep_if { |_, value| valid_data?(value) }

      # Assign a correlation if it was missing in the first context or when
      # explicitly removed
      data[CORRELATION_ID_KEY] ||= new_id

      data
    end

    private

    delegate :log_key, to: :class

    attr_reader :data

    def call_or_value(value)
      value.respond_to?(:call) ? value.call : value
    end

    def expand_data
      data.transform_values do |value|
        value = call_or_value(value)

        value if valid_data?(value)
      end.compact
    end

    def new_id
      SecureRandom.hex
    end

    def valid_data?(value)
      value == false || value.present?
    end
  end
end