tests/testthat/test-otel-ingestion.R

# ============================================================================
# OTEL INGESTION TESTS
# ============================================================================
# tests for agent 1's otel data ingestion and format detection
# these tests verify otlp json and sqlite reading capabilities

test_that("detect_otel_json identifies otlp format correctly", {
  skip_if_no_otel()

  # create mock otlp json file
  spans <- create_mock_otel_spans(sessions = 1, reactives_per_session = 2)
  otlp_file <- create_temp_otel_json(spans)

  # test otlp detection returns true
  result <- bidux:::detect_otel_json(otlp_file)
  expect_true(result)

  unlink(otlp_file)
})

test_that("detect_otel_json distinguishes shiny.telemetry from otlp", {
  skip_if_no_otel()

  # create traditional shiny.telemetry json
  telemetry_file <- create_temp_shiny_telemetry_json(sessions = 2)

  # test shiny.telemetry format returns false
  result <- bidux:::detect_otel_json(telemetry_file)
  expect_false(result)

  unlink(telemetry_file)
})

test_that("detect_otel_json handles malformed json gracefully", {
  skip_if_no_otel()

  # create invalid json file
  bad_file <- tempfile(fileext = ".json")
  writeLines(c("{", "invalid json", "}"), bad_file)

  # detect_otel_json returns FALSE for malformed JSON (doesn't error)
  result <- bidux:::detect_otel_json(bad_file)
  expect_false(result)

  unlink(bad_file)
})

test_that("detect_otel_json validates required otlp structure", {
  skip_if_no_otel()

  # create json without resourceSpans (not otlp)
  not_otlp <- tempfile(fileext = ".json")
  writeLines('{"data": [{"key": "value"}]}', not_otlp)

  result <- bidux:::detect_otel_json(not_otlp)
  expect_false(result)

  unlink(not_otlp)
})

test_that("read_otel_json parses otlp json correctly", {
  skip_if_no_otel()

  # create mock otlp data
  spans <- create_mock_otel_spans(
    sessions = 2,
    reactives_per_session = 5,
    outputs_per_session = 3
  )
  otlp_file <- create_temp_otel_json(spans)

  # read and parse
  result <- bidux:::read_otel_json(otlp_file)

  # verify result structure
  expect_true(is.data.frame(result))
  expect_gt(nrow(result), 0)

  # verify expected columns exist
  expected_cols <- c("timestamp", "session_id", "event_type")
  expect_true(all(expected_cols %in% names(result)))

  # verify span names are preserved
  expect_true("login" %in% result$event_type)
  expect_true("input" %in% result$event_type)

  unlink(otlp_file)
})

test_that("read_otel_json extracts span attributes correctly", {
  skip_if_no_otel()

  spans <- create_mock_otel_spans(sessions = 1, reactives_per_session = 2)
  otlp_file <- create_temp_otel_json(spans)

  result <- bidux:::read_otel_json(otlp_file)

  # check session_id extraction from attributes
  login_events <- result[result$event_type == "login", ]
  expect_gt(nrow(login_events), 0)
  expect_false(all(is.na(login_events$session_id)))

  unlink(otlp_file)
})

test_that("read_otel_json handles nested scopeSpans structure", {
  skip_if_no_otel()

  # create otlp with multiple scopeSpans (edge case)
  spans <- create_mock_otel_spans(sessions = 2, reactives_per_session = 3)
  otlp_file <- create_temp_otel_json(spans)

  # should flatten all spans regardless of nesting
  result <- bidux:::read_otel_json(otlp_file)

  expect_true(is.data.frame(result))
  expect_gt(nrow(result), 5) # should have multiple spans

  unlink(otlp_file)
})

test_that("read_otel_json extracts span events (errors)", {
  skip_if_no_otel()

  # create spans with error events
  spans <- create_mock_otel_spans(
    sessions = 2,
    outputs_per_session = 5,
    include_errors = TRUE
  )
  otlp_file <- create_temp_otel_json(spans)

  result <- bidux:::read_otel_json(otlp_file)

  # verify events column exists or events are extracted
  has_events <- "events" %in% names(result) || "error_message" %in% names(result)
  expect_true(has_events)

  unlink(otlp_file)
})

test_that("read_otel_json handles empty spans array", {
  skip_if_no_otel()

  # create otlp structure with no spans
  empty_otlp <- tempfile(fileext = ".json")
  empty_structure <- list(
    resourceSpans = list(
      list(
        scopeSpans = list(
          list(spans = list())
        )
      )
    )
  )

  jsonlite::write_json(empty_structure, empty_otlp, auto_unbox = TRUE)

  result <- bidux:::read_otel_json(empty_otlp)

  # should return empty dataframe with proper structure
  expect_true(is.data.frame(result))
  expect_equal(nrow(result), 0)

  unlink(empty_otlp)
})

test_that("read_otel_json validates required span fields", {
  skip_if_no_otel()

  # create otlp with missing required fields
  malformed_otlp <- tempfile(fileext = ".json")
  malformed <- list(
    resourceSpans = list(
      list(
        scopeSpans = list(
          list(
            spans = list(
              list(
                traceId = "abc123",
                # missing spanId, name, timestamps
                attributes = list()
              )
            )
          )
        )
      )
    )
  )

  jsonlite::write_json(malformed, malformed_otlp, auto_unbox = TRUE)

  # malformed spans are skipped gracefully, may return empty result
  result <- bidux:::read_otel_json(malformed_otlp)
  expect_true(is.data.frame(result))

  unlink(malformed_otlp)
})

test_that("read_otel_sqlite reads otel database tables", {
  skip_if_no_otel()
  skip_if_no_telemetry_deps()

  # create otel sqlite database
  spans <- create_mock_otel_spans(sessions = 2, reactives_per_session = 5)
  db_path <- create_temp_otel_sqlite(spans)

  # read database
  result <- bidux:::read_otel_sqlite(db_path)

  # verify structure
  expect_true(is.data.frame(result))
  expect_gt(nrow(result), 0)

  # verify expected columns
  expected_cols <- c("timestamp", "session_id", "event_type")
  expect_true(all(expected_cols %in% names(result)))

  unlink(db_path)
})

test_that("read_otel_sqlite detects correct table names", {
  skip_if_no_otel()
  skip_if_no_telemetry_deps()

  spans <- create_mock_otel_spans(sessions = 1)
  db_path <- create_temp_otel_sqlite(spans)

  # verify table detection works
  con <- DBI::dbConnect(RSQLite::SQLite(), db_path)
  tables <- DBI::dbListTables(con)

  expect_true("spans" %in% tables)
  expect_true("span_attributes" %in% tables)

  DBI::dbDisconnect(con)

  # verify read function finds these tables
  result <- bidux:::read_otel_sqlite(db_path)
  expect_true(is.data.frame(result))

  unlink(db_path)
})

test_that("read_otel_sqlite joins attributes correctly", {
  skip_if_no_otel()
  skip_if_no_telemetry_deps()

  spans <- create_mock_otel_spans(sessions = 1, reactives_per_session = 3)
  db_path <- create_temp_otel_sqlite(spans)

  result <- bidux:::read_otel_sqlite(db_path)

  # attributes should be joined or accessible
  # check for session_id or attributes column
  has_attrs <- "session_id" %in% names(result) || "attributes" %in% names(result)
  expect_true(has_attrs)

  unlink(db_path)
})

test_that("read_otel_sqlite handles missing attributes table", {
  skip_if_no_otel()
  skip_if_no_telemetry_deps()

  # create minimal database with only spans table
  db_path <- tempfile(fileext = ".sqlite")
  con <- DBI::dbConnect(RSQLite::SQLite(), db_path)

  spans_table <- data.frame(
    traceId = "abc123",
    spanId = "span001",
    parentSpanId = NA_character_,
    name = "session_start",
    startTimeUnixNano = "1609459200000000000",
    endTimeUnixNano = "1609459200100000000",
    stringsAsFactors = FALSE
  )

  DBI::dbWriteTable(con, "spans", spans_table)
  DBI::dbDisconnect(con)

  # should still read successfully (no attributes means session_id will be NA)
  result <- bidux:::read_otel_sqlite(db_path)
  expect_true(is.data.frame(result))
  expect_true("session_id" %in% names(result))

  unlink(db_path)
})

test_that("read_otel_sqlite extracts span events from events table", {
  skip_if_no_otel()
  skip_if_no_telemetry_deps()

  # create database with error events
  spans <- create_mock_otel_spans(
    sessions = 2,
    outputs_per_session = 5,
    include_errors = TRUE
  )
  db_path <- create_temp_otel_sqlite(spans)

  result <- bidux:::read_otel_sqlite(db_path)

  # should include error information
  has_errors <- "events" %in% names(result) ||
    "error_message" %in% names(result) ||
    any(grepl("error", names(result), ignore.case = TRUE))

  expect_true(has_errors)

  unlink(db_path)
})

test_that("read_otel_sqlite works with dbi connection", {
  skip_if_no_otel()
  skip_if_no_telemetry_deps()

  spans <- create_mock_otel_spans(sessions = 1)
  db_path <- create_temp_otel_sqlite(spans)

  # pass connection instead of path
  con <- DBI::dbConnect(RSQLite::SQLite(), db_path)

  result <- bidux:::read_otel_sqlite(con)

  expect_true(is.data.frame(result))
  expect_gt(nrow(result), 0)

  # connection should remain open (we don't own it)
  expect_true(DBI::dbIsValid(con))

  DBI::dbDisconnect(con)
  unlink(db_path)
})

test_that("format auto-detection chooses read_otel_json for otlp", {
  skip_if_no_otel()

  spans <- create_mock_otel_spans(sessions = 1)
  otlp_file <- create_temp_otel_json(spans)

  # auto-detect should route to otel reader
  format <- bidux:::detect_telemetry_format(otlp_file)

  # if otel detection is implemented, should return "otel_json" or similar
  # fallback: should at least return "json"
  expect_true(format %in% c("json", "otel_json", "otlp"))

  unlink(otlp_file)
})

test_that("format auto-detection chooses read_otel_sqlite for otel db", {
  skip_if_no_otel()
  skip_if_no_telemetry_deps()

  spans <- create_mock_otel_spans(sessions = 1)
  db_path <- create_temp_otel_sqlite(spans)

  # detect format
  format <- bidux:::detect_telemetry_format(db_path)

  # should detect as sqlite (with otel schema detection happening later)
  expect_equal(format, "sqlite")

  unlink(db_path)
})

test_that("format auto-detection distinguishes otel from shiny.telemetry", {
  skip_if_no_otel()

  # create both formats
  spans <- create_mock_otel_spans(sessions = 1)
  otlp_file <- create_temp_otel_json(spans)
  telemetry_file <- create_temp_shiny_telemetry_json(sessions = 1)

  # both should be detected as json initially
  otlp_format <- bidux:::detect_telemetry_format(otlp_file)
  telemetry_format <- bidux:::detect_telemetry_format(telemetry_file)

  expect_equal(otlp_format, "json")
  expect_equal(telemetry_format, "json")

  # but content-based detection should differ
  is_otlp <- bidux:::detect_otel_json(otlp_file)
  is_telemetry <- bidux:::detect_otel_json(telemetry_file)

  expect_true(is_otlp)
  expect_false(is_telemetry)

  unlink(c(otlp_file, telemetry_file))
})

test_that("malformed otlp data produces clear error messages", {
  skip_if_no_otel()

  # test missing resourceSpans
  bad_json1 <- tempfile(fileext = ".json")
  writeLines('{"spans": []}', bad_json1)

  expect_error(
    bidux:::read_otel_json(bad_json1),
    "resourceSpans|structure|format"
  )

  unlink(bad_json1)

  # test invalid json
  bad_json2 <- tempfile(fileext = ".json")
  writeLines('{"invalid": json', bad_json2)

  expect_error(
    bidux:::read_otel_json(bad_json2),
    "json|parse"
  )

  unlink(bad_json2)
})

test_that("read_otel_json handles large span counts efficiently", {
  skip_if_no_otel()
  skip_on_cran() # performance test with larger dataset

  # create large dataset
  spans <- create_mock_otel_spans(
    sessions = 10,
    reactives_per_session = 20,
    outputs_per_session = 10
  )
  otlp_file <- create_temp_otel_json(spans)

  # should complete without timeout or memory issues
  start_time <- Sys.time()
  result <- bidux:::read_otel_json(otlp_file)
  elapsed <- as.numeric(difftime(Sys.time(), start_time, units = "secs"))

  expect_true(is.data.frame(result))
  expect_gt(nrow(result), 100) # should have many spans
  expect_lt(elapsed, 5) # should complete within 5 seconds

  unlink(otlp_file)
})

test_that("read_otel_json converts spans to bidux event schema", {
  skip_if_no_otel()

  spans <- create_mock_otel_spans(sessions = 1, reactives_per_session = 3)
  otlp_file <- create_temp_otel_json(spans)

  result <- bidux:::read_otel_json(otlp_file)

  # should have bidux event schema columns
  expect_true(is.data.frame(result))
  expect_gt(nrow(result), 0)
  expect_true("event_type" %in% names(result))
  expect_true("timestamp" %in% names(result))
  expect_true("session_id" %in% names(result))

  unlink(otlp_file)
})

test_that("read_otel_json handles multiple attribute value types", {
  skip_if_no_otel()

  # create custom otlp with different attribute types
  custom_otlp <- tempfile(fileext = ".json")

  otlp_data <- list(
    resourceSpans = list(
      list(
        scopeSpans = list(
          list(
            spans = list(
              list(
                traceId = "abc123",
                spanId = "span001",
                name = "test",
                startTimeUnixNano = "1609459200000000000",
                endTimeUnixNano = "1609459200100000000",
                attributes = list(
                  list(key = "string_attr", value = list(stringValue = "test")),
                  list(key = "int_attr", value = list(intValue = 42)),
                  list(key = "double_attr", value = list(doubleValue = 3.14)),
                  list(key = "bool_attr", value = list(boolValue = TRUE))
                )
              )
            )
          )
        )
      )
    )
  )

  jsonlite::write_json(otlp_data, custom_otlp, auto_unbox = TRUE)

  # should handle all value types
  expect_no_error({
    result <- bidux:::read_otel_json(custom_otlp)
  })

  unlink(custom_otlp)
})

test_that("read_otel_sqlite handles concurrent access safely", {
  skip_if_no_otel()
  skip_if_no_telemetry_deps()
  skip_on_cran()

  spans <- create_mock_otel_spans(sessions = 2)
  db_path <- create_temp_otel_sqlite(spans)

  # multiple reads should work (read-only access)
  result1 <- bidux:::read_otel_sqlite(db_path)
  result2 <- bidux:::read_otel_sqlite(db_path)

  expect_equal(nrow(result1), nrow(result2))

  unlink(db_path)
})

test_that("otel readers handle unicode and special characters", {
  skip_if_no_otel()

  # create spans with unicode in attributes
  spans <- create_mock_otel_spans(sessions = 1)

  # modify to include unicode
  spans$name[1] <- "session_start_\u2713" # checkmark

  otlp_file <- create_temp_otel_json(spans)

  # should handle unicode correctly
  expect_no_error({
    result <- bidux:::read_otel_json(otlp_file)
  })

  unlink(otlp_file)
})

Try the bidux package in your browser

Any scripts or data that you put into this service are public.

bidux documentation built on Feb. 28, 2026, 1:06 a.m.