s-fleck / lgrExtra

Extra Appenders for the lgr Package
https://s-fleck.github.io/lgrExtra/
Other
7 stars 2 forks source link

Feature Request: Azure Log Analytics Appender #2

Open samssann opened 3 years ago

samssann commented 3 years ago

Log appender that sends logs to Azure Log Analytics. Azure Log analytics API reference https://docs.microsoft.com/en-us/rest/api/loganalytics/create-request

Azure Log Analytics would allow sending application logs to a centralized location, where they can be analysed using Kusto Query Language (KQL) and set to trigger automated alerts based on these queries.

Edit: Added use case.

s-fleck commented 3 years ago

Thanks definitely possible and probably not hard to do, but it might be a while till I get around to implementing new appenders... A pull request would be appreciated though :)

samssann commented 3 years ago

Roger! I think I won't have time until maybe the start of next year for a full PR, but I did some preliminary work. AzureAuth can't be used since the workspace_id and workspace_key are not associated with Azure AD, but the Log Analytics workspace itself. The dependency requirements grew a bit, but these can be bypassed with more code.

#' @importFrom rlang is_scalar_character abort
#' @importFrom xml2 as_list
#' @importFrom httr POST add_headers content_type_json content
#' @importFrom glue glue
#' @importFrom tibble is_tibble
#' @importFrom digest hmac
#' @importFrom lubridate tz
#' @importFrom jsonlite toJSON base64_enc base64_dec
azure_write_logs <- function(tbl, log_type, workspace_id, workspace_key) {
  stopifnot(
    is_tibble(tbl), 
    is_scalar_character(log_type),
    is_scalar_character(workspace_id),
    is_scalar_character(workspace_key)
  )
  url <- as.character(glue("https://{workspace_id}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01"))
  content <- as.character(toJSON(tbl, auto_unbox = T))
  content_len <- nchar(content)
  content_size <- as.numeric(gsub(" bytes", "", object.size(content)))
  if(content_size > 30000000L) abort("<api_error> tbl cannot exceed size of 30 MB")
  datetime <- Sys.time()
  tz(datetime) <- "GMT"
  datetime <- format(datetime, "%a, %d %b %Y %X %Z")
  message <- as.character(glue('POST\n{content_len}\napplication/json\nx-ms-date:{datetime}\n/api/logs'))
  decoded_key <- rawToChar(base64_dec(workspace_key))
  encoded_hash <- base64_enc(rawToChar(hmac(raw = T, key = decoded_key, object = message, algo = "sha256")))
  signature <- as.character(glue("SharedKey {workspace_id}:{encoded_hash}"))
  response <- POST(
    url,
    content_type_json(),
    add_headers(
      `Authorization` = signature,
      `Log-Type` = log_type,
      `x-ms-date` = datetime
    ),
    body = content
  )
  if(response$status_code != 200L) abort(glue("<api_error> {paste0(xml2::as_list(content(response))$html$body, collapse = '')}")) # abort if request did not go through
  invisible(TRUE)
}
s-fleck commented 3 years ago

ok thanks that is helpful. my main goal is right now to get lgrExtra ready for cran till January. I'll look into this issue after that

samssann commented 3 years ago

Best of luck. Many thanks for this and the lgr package. I'll let you know if I start working this issue beforehand.

samssann commented 3 years ago

Have you had a chance to look the implementation on this? It seems pretty straight forward if we create a new appender (AppenderAzureLog) than initializes like this

aal <- AppenderAzureLog$new(
  workspace_id = "xxx",
  workspace_key = "xxx",
  log_type = "test"
)

This appender should inherit the AppenderJSON (as json format is used with the http requests body)?

I did a mini proof-of-concept and it worked

# this should be a private method in the AppenderAzureLog class
upload_logs <- function(json, log_type, workspace_id, workspace_key) {
  stopifnot(
    inherits(json, "json"), 
    is_scalar_character(log_type),
    is_scalar_character(workspace_id),
    is_scalar_character(workspace_key)
  )
  url <- as.character(glue("https://{workspace_id}.ods.opinsights.azure.com/api/logs?api-version=2016-04-01"))
  content <- as.character(json)
  content_len <- nchar(content)
  content_size <- as.numeric(gsub(" bytes", "", object.size(content)))
  if(content_size > 30000000L) abort("<api_error> json cannot exceed size of 30 MB") # any ideas on how to present errors so that it matches your general idea of the package?
  datetime <- Sys.time()
  tz(datetime) <- "GMT"
  datetime <- format(datetime, "%a, %d %b %Y %X %Z")
  message <- as.character(glue('POST\n{content_len}\napplication/json\nx-ms-date:{datetime}\n/api/logs'))
  decoded_key <- rawToChar(base64_dec(workspace_key))
  encoded_hash <- base64_enc(rawToChar(hmac(raw = T, key = decoded_key, object = message, algo = "sha256")))
  signature <- as.character(glue("SharedKey {workspace_id}:{encoded_hash}"))
  response <- POST(
    url,
    content_type_json(),
    add_headers(
      `Authorization` = signature,
      `Log-Type` = log_type,
      `x-ms-date` = datetime,
      `time-generated-field` = "timestamp" # points to the timestamp field is json
    ),
    body = content
  )
  if(response$status_code != 200L) abort(glue("<api_error> {paste0(xml2::as_list(content(response))$html$body, collapse = '')}")) # abort if request did not go through
  invisible(TRUE)
}
# create event
event <- LogEvent$new(
  logger = Logger$new("dummy logger"),
  level = 200,
  timestamp = Sys.time(),
  caller = NA_character_,
  msg = "a test message",
  custom_field = "LayoutJson can handle arbitrary fields"
)
lo <- LayoutJson$new() # json layout
lo$set_timestamp_fmt("%Y-%m-%dT%H:%M:%SZ") # the data ingestion supports only the ISO 8601 format
json <- lo$format_event(event)
json
#> {"level":200,"timestamp":"2021-04-01T11:33:42Z","logger":"dummy logger","caller":null,"msg":"a test message","custom_field":"LayoutJson can handle arbitrary fields"}
upload_logs(json, "test", .id, .key) # upload log event

After waiting for a couple of minutes, this appeared in the Azure Log Analytics portal image A "_CL" suffix is added automatically to the log_type variable in the ingestion phase to point out that this indeed a custom log. The only "hard" thing is to handle errors in the http request and only delete events from the buffer if the upload is successful. But I think the framework you've done already pretty much enables this. The API has not changed in years so keeping this up-to-date seems easy.

s-fleck commented 3 years ago

Probably there should be a new AppenderHttp or AppenderRest that uses LayoutJson, as Appenders are responsible for the destination, and Layouts for the format. AppenderJson itself is kinda bad design from that standpoint, but it's really just a convenient shortcut for AppenderFile with LayoutJson.

I'm kinda busy with other things at the moment, so I'm not sure if I get around to it... If you have some experience with R6 classes you could probably hack together your own appender for the time beeing...

Anyways If I might get around to it, I would need to be able to setup a test destination. Is that easy/free with azure?

samssann commented 3 years ago

Understood. Its pretty easy to setup (and free https://azure.microsoft.com/en-us/services/monitor/). I'll manage to create a custom appender for myself, but I'll probably leave the AppenderHttp/Rest design for you when you have the time. I can share my fork when I get to building it.

s-fleck commented 3 years ago

Ok cool thanks :) I'll hope I'll have some time to work more on lgr later this year but at the moment I'm pretty swamped at work.