# Copyright (c) 2023 Contrast Security, Inc. See https://www.contrastsecurity.com/enduser-terms-0317a for more details. # frozen_string_literal: true require 'net/http' require 'contrast/utils/net_http_base' require 'contrast/components/logger' require 'contrast/utils/object_share' require 'contrast/agent/version' require 'json' module Contrast module Agent module Telemetry # This module creates a Net::HTTP client and initiates a connection to the provided result class Client < Contrast::Utils::NetHttpBase include Contrast::Components::Logger::InstanceMethods ENDPOINT = 'api/v1/telemetry/metrics' # /TelemetryEvent.path EXCEPTIONS = 'api/v1/telemetry/exceptions' # /Telemetry::Exception::Event.path SERVICE_NAME = 'Telemetry' # This method initializes the Net::HTTP client we'll need. it will validate # the connection and make the first request. If connection is valid and response # is available then the open connection is returned. # # @param url [String] # @return [Net::HTTP, nil] Return open connection or nil def initialize_connection url super(SERVICE_NAME, url, use_proxy: false, use_custom_cert: false) end # This method will be responsible for building the request. Because the telemetry collector expects to receive # multiple events in a single request, we must always wrap the event in an array, even if there is only one. # # @param event [Contrast::Agent::Telemetry::Event, Array] # @return [Net::HTTP::Post] def build_request event return unless valid_event?(event) header = { 'User-Agent' => "<#{ Contrast::Utils::ObjectShare::RUBY }>-<#{ Contrast::Agent::VERSION }>", 'Content-Type' => 'application/json' } request = Net::HTTP::Post.new(build_path(event), header) request.body = get_event_json(event) request end # This method will create the actual request and send it # @param event[Contrast::Agent::Telemetry::Event] # @param connection[Net::HTTP] def send_request event, connection return if connection.nil? || event.nil? return unless valid_event?(event) req = build_request(event) connection.request(req) end # This method will handle the response from the tenant # @param res [Net::HTTPResponse] # @return sleep_time [Integer, nil] def handle_response res status_code = res.code.to_i ready_after = if res.to_hash.keys.map(&:downcase).include?('ready-after') res['Ready-After'] else 60 end ready_after if status_code == 429 end # This method will be responsible for validating the event. Valid if event is of a known # Contrast::Agent::Telemetry type # # @param event [Object] def valid_event? event return true if event.cs__is_a?(Contrast::Agent::Telemetry::Event) return true if event.cs__is_a?(Contrast::Agent::Telemetry::StartupMetricsEvent) return true if event.cs__is_a?(Contrast::Agent::Telemetry::Exception::Event) false end private # The telemetry instance accepts any path to #{ Contrast::Agent::Telemetry::URL }#{ ENDPOINT }, using the # remainder of the path to segregate messages. # # @param event [Contrast::Agent::Telemetry::Event, Contrast::Agent::Telemetry::Exception::Event] # @return [String] the fully qualified path to send the request def build_path event endpoint = Array(event).all?(Contrast::Agent::Telemetry::Exception::Event) ? EXCEPTIONS : ENDPOINT path = endpoint == EXCEPTIONS ? Contrast::Agent::Telemetry::Exception::Event.path : event.path "#{ Contrast::Agent::Telemetry::Base::URL }#{ endpoint }#{ path }" end # Helper Method to get json representation of Telemetry Event data, handles error on to_json # # @param event [Contrast::Agent::Telemetry::Event, Array] # @return [String] - JSON def get_event_json event Array(event.to_controlled_hash).to_json rescue Exception => e # rubocop:disable Lint/RescueException logger.error('[Telemetry] Unable to convert TelemetryEvent to JSON string', e, hsh) raise(e) end end end end end