|
| 1 | +# frozen_string_literal: true |
| 2 | + |
| 3 | +require "net/http" |
1 | 4 | desc "Post gc stats to discord channel" |
2 | 5 |
|
| 6 | +CODE_BLOCK_DELIMITER_END = "\n...```" |
| 7 | +CODE_BLOCK_DELIMITER_START = "```json\n" |
| 8 | +DISCORD_MESSAGE_LIMIT = 2000 |
| 9 | +TOTAL_DELIMITER_LENGTH = CODE_BLOCK_DELIMITER_END.length + CODE_BLOCK_DELIMITER_START.length |
| 10 | + |
3 | 11 | task post_gc_stat_to_discord: :environment do |
4 | | - require "net/http" |
| 12 | + fetch_env_vars => {access_token:, discord_webhook_url:} |
| 13 | + |
| 14 | + stats = fetch_memory_profile(access_token) |
| 15 | + message_chunks = chunk_profile_stats(stats) |
| 16 | + send_discord_message_chunked(message_chunks) |
| 17 | +end |
| 18 | + |
| 19 | +def chunk_long_code_block_message(long_message) |
| 20 | + if long_message.length <= DISCORD_MESSAGE_LIMIT |
| 21 | + return [long_message] |
| 22 | + end |
| 23 | + |
| 24 | + first_chunk, long_message = split_single_chunk(long_message, DISCORD_MESSAGE_LIMIT - CODE_BLOCK_DELIMITER_END.length) |
| 25 | + |
| 26 | + chunks = [first_chunk] |
| 27 | + |
| 28 | + while long_message.length > DISCORD_MESSAGE_LIMIT |
| 29 | + puts long_message |
| 30 | + chunk, long_message = split_single_chunk(long_message, DISCORD_MESSAGE_LIMIT - TOTAL_DELIMITER_LENGTH) |
| 31 | + |
| 32 | + chunks.push(chunk) |
| 33 | + end |
| 34 | + |
| 35 | + chunks.push(long_message) |
| 36 | + |
| 37 | + chunks |
| 38 | +end |
| 39 | + |
| 40 | +def chunk_profile_stats(stats_as_json_string) |
| 41 | + # parsing the json string consumes memory |
| 42 | + old_object_count = get_old_object_count_low_memory_usage(stats_as_json_string) |
| 43 | + sample_hour = get_sample_time_low_memory_usage(stats_as_json_string) |
| 44 | + |
| 45 | + largest_old_objects_by_class = get_json_array_value_as_string(stats_as_json_string, "\"largest_old_objects_by_class\": [") |
| 46 | + most_common_old_object_classes = get_json_array_value_as_string(stats_as_json_string, "\"most_common_old_object_classes\": [") |
| 47 | + most_common_old_strings = get_json_array_value_as_string(stats_as_json_string, "\"most_common_old_strings\": [") |
| 48 | + |
| 49 | + part_1 = <<~MULTILINE |
| 50 | + Hour: #{sample_hour} |
| 51 | + Old Object Count: #{old_object_count} |
| 52 | +
|
| 53 | + Largest Classes in Old Objects |
| 54 | + ```ruby |
| 55 | + #{largest_old_objects_by_class} |
| 56 | + ``` |
| 57 | + MULTILINE |
5 | 58 |
|
6 | | - url = URI("https://casavolunteertracking.org/health/old_objects?token=#{ENV["GC_ACCESS_TOKEN"]}") |
| 59 | + part_2 = <<~MULTILINE |
| 60 | + Most Common Old Objects |
| 61 | + ```ruby |
| 62 | + #{most_common_old_object_classes} |
| 63 | + ``` |
| 64 | + MULTILINE |
| 65 | + |
| 66 | + part_3 = <<~MULTILINE |
| 67 | + Most Common Old Strings |
| 68 | + ```ruby |
| 69 | + #{most_common_old_strings} |
| 70 | + ``` |
| 71 | + MULTILINE |
| 72 | + |
| 73 | + chunk_long_code_block_message(part_1).concat(chunk_long_code_block_message(part_2)).concat(chunk_long_code_block_message(part_3)) |
| 74 | +end |
| 75 | + |
| 76 | +def is_valid_discord_message(value) |
| 77 | + value.is_a?(String) && value.length <= DISCORD_MESSAGE_LIMIT |
| 78 | +end |
| 79 | + |
| 80 | +def enclose_chunked_code_blocks(chunks) |
| 81 | + if chunks.length < 2 |
| 82 | + return chunks |
| 83 | + end |
| 84 | + |
| 85 | + chunks[0] += CODE_BLOCK_DELIMITER_END |
| 86 | + chunks[-1] = CODE_BLOCK_DELIMITER_START + chunks[-1] |
| 87 | + |
| 88 | + 1..chunks.length - 2.each do |i| |
| 89 | + chunks[i] = CODE_BLOCK_DELIMITER_START + chunks[i] + CODE_BLOCK_DELIMITER_END |
| 90 | + end |
| 91 | +end |
| 92 | + |
| 93 | +def fetch_env_vars |
| 94 | + access_token = ENV.fetch("GC_ACCESS_TOKEN") |
| 95 | + discord_webhook_url = ENV.fetch("DISCORD_WEBHOOK_URL") |
| 96 | + |
| 97 | + { |
| 98 | + access_token:, |
| 99 | + discord_webhook_url: |
| 100 | + } |
| 101 | +end |
| 102 | + |
| 103 | +def fetch_memory_profile(access_token) |
| 104 | + url = URI("https://casavolunteertracking.org/health/old_objects?token=#{access_token}") |
7 | 105 | response = Net::HTTP.get_response(url) |
8 | 106 |
|
9 | 107 | unless response.is_a?(Net::HTTPSuccess) |
10 | 108 | raise "Failed to fetch GC stats. HTTP status code:#{response.code}" |
11 | 109 | end |
12 | 110 |
|
13 | | - stats = response.body |
| 111 | + response.body |
| 112 | +end |
| 113 | + |
| 114 | +def get_array_closing_bracket_index(opening_bracket_index, stats_as_json_string) |
| 115 | + nest_level = 1 |
| 116 | + |
| 117 | + search_index = opening_bracket_index + 1 |
| 118 | + |
| 119 | + while search_index < stats_as_json_string.length |
| 120 | + current_character = stats_as_json_string[search_index] |
| 121 | + |
| 122 | + if current_character == "[" |
| 123 | + nest_level += 1 |
| 124 | + elsif current_character == "]" |
| 125 | + nest_level -= 1 |
| 126 | + |
| 127 | + if nest_level == 0 |
| 128 | + return search_index |
| 129 | + end |
| 130 | + end |
| 131 | + |
| 132 | + search_index += 1 |
| 133 | + end |
| 134 | + |
| 135 | + -1 |
| 136 | +end |
| 137 | + |
| 138 | +def get_json_array_value_as_string(stats_as_json_string, string_with_key) |
| 139 | + start_index = stats_as_json_string.index(string_with_key) + string_with_key.length - 1 |
| 140 | + stop_index = get_array_closing_bracket_index(start_index, stats_as_json_string) |
| 141 | + |
| 142 | + stats_as_json_string[start_index, stop_index - start_index + 1].gsub("\n ", "\n") |
| 143 | +end |
| 144 | + |
| 145 | +def get_old_object_count_low_memory_usage(stats_as_json_string) |
| 146 | + old_object_count_marker = '"old_object_count": ' |
| 147 | + old_object_count_start_index = stats_as_json_string.index(/#{old_object_count_marker}\d+,/) + old_object_count_marker.length |
| 148 | + old_object_count_stop_index = old_object_count_start_index |
| 149 | + |
| 150 | + while stats_as_json_string[old_object_count_stop_index] != "," |
| 151 | + old_object_count_stop_index += 1 |
| 152 | + end |
| 153 | + |
| 154 | + stats_as_json_string[old_object_count_start_index, old_object_count_stop_index - old_object_count_start_index] |
| 155 | +end |
14 | 156 |
|
15 | | - unless ENV["DISCORD_WEBHOOK_URL"].nil? |
16 | | - discord_message = <<~MULTILINE |
17 | | - ```json |
18 | | - #{stats} |
19 | | - ``` |
20 | | - MULTILINE |
| 157 | +def get_sample_time_low_memory_usage(stats_as_json_string) |
| 158 | + sample_time_marker = 'sample_time": "' |
| 159 | + sample_time_start_index = stats_as_json_string.index(/#{sample_time_marker}\d+"/) + sample_time_marker.length |
| 160 | + sample_time_count_stop_index = sample_time_start_index |
21 | 161 |
|
22 | | - payload = {content: discord_message}.to_json |
| 162 | + while stats_as_json_string[sample_time_count_stop_index] != '"' |
| 163 | + sample_time_count_stop_index += 1 |
| 164 | + end |
23 | 165 |
|
24 | | - uri = URI.parse(ENV["DISCORD_WEBHOOK_URL"]) |
25 | | - http = Net::HTTP.new(uri.host, uri.port) |
26 | | - http.use_ssl = (uri.scheme == "https") # Use SSL for HTTPS |
| 166 | + stats_as_json_string[sample_time_start_index, sample_time_count_stop_index - sample_time_start_index] |
| 167 | +end |
27 | 168 |
|
28 | | - request = Net::HTTP::Post.new(uri.path, {"Content-Type" => "application/json"}) |
29 | | - request.body = payload |
| 169 | +def send_discord_message(message) |
| 170 | + unless is_valid_discord_message(message) |
| 171 | + raise ArgumentError, "argument message must be a string with a maximum length of #{DISCORD_MESSAGE_LIMIT} characters" |
| 172 | + end |
| 173 | + |
| 174 | + payload = {content: message}.to_json |
| 175 | + |
| 176 | + uri = URI.parse(ENV["DISCORD_WEBHOOK_URL"]) |
| 177 | + http = Net::HTTP.new(uri.host, uri.port) |
| 178 | + http.use_ssl = (uri.scheme == "https") |
| 179 | + |
| 180 | + request = Net::HTTP::Post.new(uri.path, {"Content-Type" => "application/json"}) |
| 181 | + request.body = payload |
| 182 | + |
| 183 | + http.request(request) |
| 184 | +end |
| 185 | + |
| 186 | +def send_discord_message_chunked(message_chunks) |
| 187 | + message_chunks.each do |chunk| |
| 188 | + request_result = send_discord_message(chunk) |
| 189 | + verify_discord_message_posted(request_result) |
| 190 | + sleep 0.5 |
| 191 | + end |
| 192 | +end |
| 193 | + |
| 194 | +def split_single_chunk(message, max_chunk_size) |
| 195 | + if message.length <= max_chunk_size |
| 196 | + return ["", message] |
| 197 | + end |
| 198 | + |
| 199 | + last_newline_index = max_chunk_size - 1 |
| 200 | + |
| 201 | + while last_newline_index > 0 && message[last_newline_index] != "\n" |
| 202 | + last_newline_index -= 1 |
| 203 | + end |
| 204 | + |
| 205 | + if last_newline_index == 0 |
| 206 | + [message[0, max_chunk_size], message[max_chunk_size, message.length]] |
| 207 | + else |
| 208 | + [message[0, last_newline_index], message[last_newline_index + 1, message.length]] |
| 209 | + end |
| 210 | +end |
30 | 211 |
|
31 | | - http.request(request) |
| 212 | +def verify_discord_message_posted(request_result) |
| 213 | + status_code_as_number = Integer(request_result.code) |
| 214 | + unless status_code_as_number >= 200 && status_code_as_number < 300 |
| 215 | + raise "Failed to send discord message. HTTP status code:#{request_result.code}" |
32 | 216 | end |
33 | 217 | end |
0 commit comments