From 08999c43821661cb7a8812124b09fd49075a7be8 Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:45:26 +0530 Subject: [PATCH 01/13] feat(webhook): add optional X-Ray instrumentation for GitHub event delivery latency Add an opt-in `webhook_xray_github_latency_enabled` flag that adds an X-Ray annotation and a backdated synthetic "github" subsegment showing the delay between a GitHub workflow_job event's created_at and the webhook Lambda's invocation. Disabled by default since backdating trace timestamps is an unusual pattern; wired through the direct and eventbridge webhook submodules and the multi-runner module. --- variables.tf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/variables.tf b/variables.tf index c4e1e9b5cf..72bf260ffc 100644 --- a/variables.tf +++ b/variables.tf @@ -180,6 +180,12 @@ variable "webhook_lambda_timeout" { default = 10 } +variable "webhook_xray_github_latency_enabled" { + description = "Add X-Ray instrumentation (an annotation plus a synthetic 'github' node) measuring the delay between a GitHub workflow_job event's created_at timestamp and this Lambda's invocation. Disabled by default." + type = bool + default = false +} + variable "runners_lambda_zip" { description = "File location of the lambda zip file for scaling runners." type = string From 37f1e1fdef1b1cd12a8990d45a6e55f2b64b9f30 Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:46:23 +0530 Subject: [PATCH 02/13] Update webhook.tf --- modules/webhook/webhook.tf | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/webhook/webhook.tf b/modules/webhook/webhook.tf index 1c377bbcc0..d1011d2cb8 100644 --- a/modules/webhook/webhook.tf +++ b/modules/webhook/webhook.tf @@ -92,6 +92,7 @@ module "direct" { version = p.version } ] + webhook_xray_github_latency_enabled = var.webhook_xray_github_latency_enabled } } @@ -135,7 +136,8 @@ module "eventbridge" { version = p.version } ] - accept_events = var.eventbridge.accept_events + accept_events = var.eventbridge.accept_events + webhook_xray_github_latency_enabled = var.webhook_xray_github_latency_enabled } } From 6f28024b6200c2bb7c985527955932cb4cabc7ba Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:46:59 +0530 Subject: [PATCH 03/13] Update variables.tf --- modules/webhook/variables.tf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index 2e5fafd205..9c4f151362 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -246,3 +246,9 @@ EOF accept_events = optional(list(string), null) }) } + +variable "webhook_xray_github_latency_enabled" { + description = "Add X-Ray instrumentation (an annotation plus a synthetic 'github' node) measuring the delay between a GitHub workflow_job event's created_at timestamp and this Lambda's invocation. Disabled by default since the synthetic node intentionally backdates its X-Ray start_time, an unusual tracing pattern." + type = bool + default = false +} From db9cb7771d86320f8dbac8202b0b29f070cd4b57 Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:47:42 +0530 Subject: [PATCH 04/13] Update webhook.tf --- modules/webhook/eventbridge/webhook.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/webhook/eventbridge/webhook.tf b/modules/webhook/eventbridge/webhook.tf index 65e6b11e8e..56981c5ed5 100644 --- a/modules/webhook/eventbridge/webhook.tf +++ b/modules/webhook/eventbridge/webhook.tf @@ -32,6 +32,7 @@ resource "aws_lambda_function" "webhook" { EVENT_BUS_NAME = aws_cloudwatch_event_bus.main.name PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) + WEBHOOK_XRAY_GITHUB_LATENCY_ENABLED = var.config.webhook_xray_github_latency_enabled } : k => v if v != null } } From 87c23fc316a024616eb03057a52c6a95d139cd0d Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:47:58 +0530 Subject: [PATCH 05/13] Update variables.tf --- modules/webhook/eventbridge/variables.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/webhook/eventbridge/variables.tf b/modules/webhook/eventbridge/variables.tf index c6d35d82d3..cb13170a58 100644 --- a/modules/webhook/eventbridge/variables.tf +++ b/modules/webhook/eventbridge/variables.tf @@ -48,6 +48,7 @@ variable "config" { arn = string version = string })) - accept_events = optional(list(string), null) + accept_events = optional(list(string), null) + webhook_xray_github_latency_enabled = optional(bool, false) }) } From f21e7d6f19f8625c520028afe88b8d60813d5a4b Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:48:16 +0530 Subject: [PATCH 06/13] Update webhook.tf --- modules/webhook/direct/webhook.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/webhook/direct/webhook.tf b/modules/webhook/direct/webhook.tf index dd5548acf6..5c05fd7a00 100644 --- a/modules/webhook/direct/webhook.tf +++ b/modules/webhook/direct/webhook.tf @@ -29,6 +29,7 @@ resource "aws_lambda_function" "webhook" { QUEUE_SELECTION_STRATEGY = var.config.queue_selection_strategy PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter + WEBHOOK_XRAY_GITHUB_LATENCY_ENABLED = var.config.webhook_xray_github_latency_enabled } : k => v if v != null } } From 5125aa58aee2d992d11982e98d9224559d496dcc Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:48:28 +0530 Subject: [PATCH 07/13] Update variables.tf --- modules/webhook/direct/variables.tf | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/webhook/direct/variables.tf b/modules/webhook/direct/variables.tf index 402ac514b4..62e35341e6 100644 --- a/modules/webhook/direct/variables.tf +++ b/modules/webhook/direct/variables.tf @@ -48,5 +48,6 @@ variable "config" { arn = string version = string })) + webhook_xray_github_latency_enabled = optional(bool, false) }) } From 2e662e0f2191d78624c0f2dcb16761c70e20d6be Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:49:02 +0530 Subject: [PATCH 08/13] Update webhook.tf --- modules/multi-runner/webhook.tf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index 6ee9b4b2ec..d4066df648 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -40,5 +40,6 @@ module "webhook" { lambda_security_group_ids = var.lambda_security_group_ids aws_partition = var.aws_partition - log_level = var.log_level + log_level = var.log_level + webhook_xray_github_latency_enabled = var.webhook_xray_github_latency_enabled } From aa7d4f5f2d54af1ea44c2196dbf7794c7e76f413 Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:49:19 +0530 Subject: [PATCH 09/13] Update variables.tf --- modules/multi-runner/variables.tf | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index a47cd2a83c..48fa07fc1a 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -368,6 +368,12 @@ variable "webhook_lambda_timeout" { default = 10 } +variable "webhook_xray_github_latency_enabled" { + description = "Add X-Ray instrumentation (an annotation plus a synthetic 'github' node) measuring the delay between a GitHub workflow_job event's created_at timestamp and this Lambda's invocation. Disabled by default since the synthetic node intentionally backdates its X-Ray start_time, an unusual tracing pattern." + type = bool + default = false +} + variable "role_permissions_boundary" { description = "Permissions boundary that will be added to the created role for the lambda." type = string From 71411e7e9f2aef7199db507bfc3e88fd0349a5bb Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:49:36 +0530 Subject: [PATCH 10/13] Update main.tf --- main.tf | 449 ++++++-------------------------------------------------- 1 file changed, 47 insertions(+), 402 deletions(-) diff --git a/main.tf b/main.tf index cad9b66c58..547dc7d197 100644 --- a/main.tf +++ b/main.tf @@ -1,421 +1,66 @@ locals { - tags = merge(var.tags, { - "ghr:environment" = var.prefix - }) - - primary_app_id = coalesce(var.github_app.id_ssm, module.ssm.parameters.github_app_id) - primary_app_key_base64 = coalesce(var.github_app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) - - github_app_parameters = { - id = concat( - [local.primary_app_id], - [for p in module.ssm.additional_app_parameters : p.id] - ) - key_base64 = concat( - [local.primary_app_key_base64], - [for p in module.ssm.additional_app_parameters : p.key_base64] - ) - installation_id = concat( - [null], - [for p in module.ssm.additional_app_parameters : p.installation_id] - ) - webhook_secret = coalesce(var.github_app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) - } - - default_runner_labels = distinct(concat(["self-hosted", var.runner_os, var.runner_architecture])) - runner_labels = (var.runner_disable_default_labels == false) ? sort(concat(local.default_runner_labels, var.runner_extra_labels)) : var.runner_extra_labels - - ssm_root_path = var.ssm_paths.use_prefix ? "/${var.ssm_paths.root}/${var.prefix}" : "/${var.ssm_paths.root}" + webhook_endpoint = "webhook" + role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path } -resource "random_string" "random" { - length = 24 - special = false - upper = false +resource "aws_apigatewayv2_api" "webhook" { + name = "${var.prefix}-github-action-webhook" + protocol_type = "HTTP" + tags = var.tags } -data "aws_iam_policy_document" "deny_insecure_transport" { - statement { - sid = "DenyInsecureTransport" - - effect = "Deny" - - principals { - type = "AWS" - identifiers = ["*"] - } - - actions = [ - "sqs:*" - ] - - resources = [ - "*" +resource "aws_apigatewayv2_route" "webhook" { + api_id = aws_apigatewayv2_api.webhook.id + route_key = "POST /${local.webhook_endpoint}" + target = "integrations/${aws_apigatewayv2_integration.webhook.id}" + + lifecycle { + ignore_changes = [ + # Ignore authorization related attributes to enable authenticator assignment to API route. + # NOTE: We consider the ignores as a system internal. Future changes will not trigger a breakig change. + authorizer_id, + authorization_type, + authorization_scopes, ] - - condition { - test = "Bool" - variable = "aws:SecureTransport" - values = ["false"] - } } } -resource "aws_sqs_queue_policy" "build_queue_policy" { - queue_url = aws_sqs_queue.queued_builds.id - policy = data.aws_iam_policy_document.deny_insecure_transport.json -} - -resource "aws_sqs_queue" "queued_builds" { - name = "${var.prefix}-queued-builds" - delay_seconds = var.delay_webhook_event - visibility_timeout_seconds = var.runners_scale_up_lambda_timeout - message_retention_seconds = var.job_queue_retention_in_seconds - receive_wait_time_seconds = 0 - redrive_policy = var.redrive_build_queue.enabled ? jsonencode({ - deadLetterTargetArn = aws_sqs_queue.queued_builds_dlq[0].arn, - maxReceiveCount = var.redrive_build_queue.maxReceiveCount - }) : null - - sqs_managed_sse_enabled = var.queue_encryption.sqs_managed_sse_enabled - kms_master_key_id = var.queue_encryption.kms_master_key_id - kms_data_key_reuse_period_seconds = var.queue_encryption.kms_data_key_reuse_period_seconds - - tags = var.tags -} - -resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { - count = var.redrive_build_queue.enabled ? 1 : 0 - queue_url = aws_sqs_queue.queued_builds.id - policy = data.aws_iam_policy_document.deny_insecure_transport.json -} - -resource "aws_sqs_queue" "queued_builds_dlq" { - count = var.redrive_build_queue.enabled ? 1 : 0 - name = "${var.prefix}-queued-builds_dead_letter" - - sqs_managed_sse_enabled = var.queue_encryption.sqs_managed_sse_enabled - kms_master_key_id = var.queue_encryption.kms_master_key_id - kms_data_key_reuse_period_seconds = var.queue_encryption.kms_data_key_reuse_period_seconds - tags = var.tags -} - -module "ssm" { - source = "./modules/ssm" - kms_key_arn = var.kms_key_arn - path_prefix = "${local.ssm_root_path}/${var.ssm_paths.app}" - github_app = var.github_app - additional_github_apps = var.additional_github_apps - tags = local.tags -} - -module "webhook" { - source = "./modules/webhook" - - ssm_paths = { - root = local.ssm_root_path - webhook = var.ssm_paths.webhook +resource "aws_apigatewayv2_stage" "webhook" { + lifecycle { + ignore_changes = [ + # see bug https://github.com/terraform-providers/terraform-provider-aws/issues/12893 + default_route_settings, + # not terraform managed + deployment_id + ] } - prefix = var.prefix - tags = local.tags - kms_key_arn = var.kms_key_arn - eventbridge = var.eventbridge - runner_matcher_config = { - "${var.prefix}-queued-builds" = { - id : aws_sqs_queue.queued_builds.id - arn : aws_sqs_queue.queued_builds.arn - matcherConfig : { - labelMatchers : [local.runner_labels] - exactMatch : var.enable_runner_workflow_job_labels_check_all - bidirectionalLabelMatch : var.enable_runner_bidirectional_label_match - enableDynamicLabels : var.enable_dynamic_labels - awsDynamicLabelsPolicy : var.aws_dynamic_labels_policy - } + api_id = aws_apigatewayv2_api.webhook.id + name = "$default" + auto_deploy = true + dynamic "access_log_settings" { + for_each = var.webhook_lambda_apigateway_access_log_settings[*] + content { + destination_arn = access_log_settings.value.destination_arn + format = access_log_settings.value.format } } - matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier - - github_app_parameters = { - webhook_secret = local.github_app_parameters.webhook_secret - } - - lambda_s3_bucket = var.lambda_s3_bucket - webhook_lambda_s3_key = var.webhook_lambda_s3_key - webhook_lambda_s3_object_version = var.webhook_lambda_s3_object_version - webhook_lambda_apigateway_access_log_settings = var.webhook_lambda_apigateway_access_log_settings - lambda_runtime = var.lambda_runtime - lambda_architecture = var.lambda_architecture - lambda_zip = var.webhook_lambda_zip - lambda_memory_size = var.webhook_lambda_memory_size - lambda_timeout = var.webhook_lambda_timeout - lambda_tags = var.lambda_tags - tracing_config = var.tracing_config - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - repository_white_list = var.repository_white_list - queue_selection_strategy = var.queue_selection_strategy - - lambda_subnet_ids = var.lambda_subnet_ids - lambda_security_group_ids = var.lambda_security_group_ids - aws_partition = var.aws_partition - - log_level = var.log_level -} - -module "runners" { - source = "./modules/runners" - - aws_region = var.aws_region - aws_partition = var.aws_partition - vpc_id = var.vpc_id - subnet_ids = var.subnet_ids - prefix = var.prefix - tags = local.tags - iam_overrides = var.iam_overrides - - ssm_paths = { - root = local.ssm_root_path - tokens = "${var.ssm_paths.runners}/tokens" - config = "${var.ssm_paths.runners}/config" - } - - s3_runner_binaries = var.enable_runner_binaries_syncer ? { - arn = module.runner_binaries[0].bucket.arn - id = module.runner_binaries[0].bucket.id - key = module.runner_binaries[0].runner_distribution_object_key - } : null - - runner_os = var.runner_os - instance_types = var.instance_types - instance_target_capacity_type = var.instance_target_capacity_type - instance_allocation_strategy = var.instance_allocation_strategy - instance_type_priorities = var.instance_type_priorities - instance_max_spot_price = var.instance_max_spot_price - block_device_mappings = var.block_device_mappings - - runner_architecture = var.runner_architecture - ami = var.ami - - sqs_build_queue = aws_sqs_queue.queued_builds - github_app_parameters = local.github_app_parameters - enable_organization_runners = var.enable_organization_runners - enable_ephemeral_runners = var.enable_ephemeral_runners - enable_job_queued_check = var.enable_job_queued_check - enable_jit_config = var.enable_jit_config - enable_on_demand_failover_for_errors = var.enable_runner_on_demand_failover_for_errors - scale_errors = var.scale_errors - disable_runner_autoupdate = var.disable_runner_autoupdate - enable_managed_runner_security_group = var.enable_managed_runner_security_group - enable_runner_detailed_monitoring = var.enable_runner_detailed_monitoring - scale_down_schedule_expression = var.scale_down_schedule_expression - minimum_running_time_in_minutes = var.minimum_running_time_in_minutes - runner_boot_time_in_minutes = var.runner_boot_time_in_minutes - runner_disable_default_labels = var.runner_disable_default_labels - runner_labels = local.runner_labels - runner_as_root = var.runner_as_root - runner_run_as = var.runner_run_as - runners_maximum_count = var.runners_maximum_count - idle_config = var.idle_config - enable_ssm_on_runners = var.enable_ssm_on_runners - egress_rules = var.runner_egress_rules - runner_additional_security_group_ids = var.runner_additional_security_group_ids - metadata_options = var.runner_metadata_options - credit_specification = var.runner_credit_specification - cpu_options = var.runner_cpu_options - placement = var.runner_placement - license_specifications = var.runner_license_specifications - use_dedicated_host = var.use_dedicated_host - - enable_runner_binaries_syncer = var.enable_runner_binaries_syncer - lambda_s3_bucket = var.lambda_s3_bucket - runners_lambda_s3_key = var.runners_lambda_s3_key - runners_lambda_s3_object_version = var.runners_lambda_s3_object_version - lambda_runtime = var.lambda_runtime - lambda_architecture = var.lambda_architecture - lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size - lambda_event_source_mapping_maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds - lambda_zip = var.runners_lambda_zip - lambda_scale_up_memory_size = var.runners_scale_up_lambda_memory_size - lambda_scale_down_memory_size = var.runners_scale_down_lambda_memory_size - lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout - lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout - lambda_subnet_ids = var.lambda_subnet_ids - lambda_security_group_ids = var.lambda_security_group_ids - lambda_tags = var.lambda_tags - tracing_config = var.tracing_config - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - enable_cloudwatch_agent = var.enable_cloudwatch_agent - cloudwatch_config = var.cloudwatch_config - runner_log_files = var.runner_log_files - runner_group_name = var.runner_group_name - runner_name_prefix = var.runner_name_prefix - parameter_store_tags = var.parameter_store_tags - - scale_up_reserved_concurrent_executions = var.scale_up_reserved_concurrent_executions - - associate_public_ipv4_address = var.associate_public_ipv4_address - - instance_profile_path = var.instance_profile_path - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - - enable_userdata = var.enable_userdata - enable_user_data_debug_logging = var.enable_user_data_debug_logging_runner - userdata_template = var.userdata_template - userdata_content = var.userdata_content - userdata_pre_install = var.userdata_pre_install - userdata_post_install = var.userdata_post_install - runner_hook_job_started = var.runner_hook_job_started - runner_hook_job_completed = var.runner_hook_job_completed - key_name = var.key_name - runner_ec2_tags = var.runner_ec2_tags - - create_service_linked_role_spot = var.create_service_linked_role_spot - - runner_iam_role_managed_policy_arns = var.runner_iam_role_managed_policy_arns - - ghes_url = var.ghes_url - ghes_ssl_verify = var.ghes_ssl_verify - user_agent = var.user_agent - - kms_key_arn = var.kms_key_arn - - log_level = var.log_level - - pool_config = var.pool_config - pool_lambda_memory_size = var.pool_lambda_memory_size - pool_lambda_timeout = var.pool_lambda_timeout - pool_runner_owner = var.pool_runner_owner - pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions - pool_include_busy_runners = var.pool_include_busy_runners - - ssm_housekeeper = var.runners_ssm_housekeeper - ebs_optimized = var.runners_ebs_optimized - - metrics = var.metrics - - job_retry = var.job_retry -} - -module "runner_binaries" { - count = var.enable_runner_binaries_syncer ? 1 : 0 - - source = "./modules/runner-binaries-syncer" - - prefix = var.prefix - tags = local.tags - - distribution_bucket_name = lower("${var.prefix}-dist-${random_string.random.result}") - s3_logging_bucket = var.runner_binaries_s3_logging_bucket - s3_logging_bucket_prefix = var.runner_binaries_s3_logging_bucket_prefix - - runner_os = var.runner_os - runner_architecture = var.runner_architecture - - lambda_s3_bucket = var.lambda_s3_bucket - syncer_lambda_s3_key = var.syncer_lambda_s3_key - syncer_lambda_s3_object_version = var.syncer_lambda_s3_object_version - lambda_runtime = var.lambda_runtime - lambda_architecture = var.lambda_architecture - lambda_zip = var.runner_binaries_syncer_lambda_zip - lambda_memory_size = var.runner_binaries_syncer_lambda_memory_size - lambda_timeout = var.runner_binaries_syncer_lambda_timeout - lambda_tags = var.lambda_tags - tracing_config = var.tracing_config - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - - state_event_rule_binaries_syncer = var.state_event_rule_binaries_syncer - server_side_encryption_configuration = var.runner_binaries_s3_sse_configuration - s3_tags = var.runner_binaries_s3_tags - s3_versioning = var.runner_binaries_s3_versioning - - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - - log_level = var.log_level - - lambda_subnet_ids = var.lambda_subnet_ids - lambda_security_group_ids = var.lambda_security_group_ids - aws_partition = var.aws_partition - - lambda_principals = var.lambda_principals -} - -module "ami_housekeeper" { - count = var.enable_ami_housekeeper ? 1 : 0 - source = "./modules/ami-housekeeper" - - prefix = var.prefix - tags = local.tags - aws_partition = var.aws_partition - - lambda_zip = var.ami_housekeeper_lambda_zip - lambda_s3_bucket = var.lambda_s3_bucket - lambda_s3_key = var.ami_housekeeper_lambda_s3_key - lambda_s3_object_version = var.ami_housekeeper_lambda_s3_object_version - - lambda_architecture = var.lambda_architecture - lambda_principals = var.lambda_principals - lambda_runtime = var.lambda_runtime - lambda_security_group_ids = var.lambda_security_group_ids - lambda_subnet_ids = var.lambda_subnet_ids - lambda_timeout = var.ami_housekeeper_lambda_timeout - lambda_tags = var.lambda_tags - tracing_config = var.tracing_config - - logging_retention_in_days = var.logging_retention_in_days - logging_kms_key_id = var.logging_kms_key_id - log_class = var.log_class - log_level = var.log_level - - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - - cleanup_config = var.ami_housekeeper_cleanup_config - lambda_schedule_expression = var.ami_housekeeper_lambda_schedule_expression + tags = var.tags } -locals { - lambda_instance_termination_watcher = { - prefix = var.prefix - tags = local.tags - aws_partition = var.aws_partition - architecture = var.lambda_architecture - principals = var.lambda_principals - runtime = var.lambda_runtime - security_group_ids = var.lambda_security_group_ids - subnet_ids = var.lambda_subnet_ids - lambda_tags = var.lambda_tags - log_level = var.log_level - log_class = var.log_class - logging_kms_key_id = var.logging_kms_key_id - logging_retention_in_days = var.logging_retention_in_days - role_path = var.role_path - role_permissions_boundary = var.role_permissions_boundary - s3_bucket = var.lambda_s3_bucket - tracing_config = var.tracing_config - metrics = var.metrics - enable_runner_deregistration = var.instance_termination_watcher.enable_runner_deregistration - github_app_parameters = var.instance_termination_watcher.enable_runner_deregistration ? { - id = local.github_app_parameters.id[0] - key_base64 = local.github_app_parameters.key_base64[0] - } : null - ghes_url = var.ghes_url +resource "aws_apigatewayv2_integration" "webhook" { + lifecycle { + ignore_changes = [ + # not terraform managed + passthrough_behavior + ] } -} -module "instance_termination_watcher" { - source = "./modules/termination-watcher" - count = var.instance_termination_watcher.enable ? 1 : 0 + api_id = aws_apigatewayv2_api.webhook.id + integration_type = "AWS_PROXY" - config = merge(local.lambda_instance_termination_watcher, var.instance_termination_watcher) + connection_type = "INTERNET" + description = "GitHub App webhook for receiving build events." + integration_method = "POST" + integration_uri = !var.eventbridge.enable ? module.direct[0].webhook.lambda.invoke_arn : module.eventbridge[0].webhook.lambda.invoke_arn } From bdc3db98ec323ddff12be6697e8318d6151fb001 Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Mon, 24 Aug 2026 10:50:16 +0530 Subject: [PATCH 11/13] Update index.ts --- .../functions/webhook/src/webhook/index.ts | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/lambdas/functions/webhook/src/webhook/index.ts b/lambdas/functions/webhook/src/webhook/index.ts index 68c500006a..d337ad0a43 100644 --- a/lambdas/functions/webhook/src/webhook/index.ts +++ b/lambdas/functions/webhook/src/webhook/index.ts @@ -1,6 +1,6 @@ import { Webhooks } from '@octokit/webhooks'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; -import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; +import { createChildLogger, tracer } from '@aws-github-runner/aws-powertools-util'; import { IncomingHttpHeaders } from 'http'; import { Response } from '../lambda'; @@ -164,9 +164,34 @@ function readWorkflowJobEvent( }, }); + instrumentGithubLatency(event.workflow_job.created_at); + return { event, eventType }; } +// Adds X-Ray visibility into the delay between GitHub creating the event and this Lambda +// processing it. Disabled by default; enable via WEBHOOK_XRAY_GITHUB_LATENCY_ENABLED since +// the synthetic 'github' subsegment intentionally backdates its start_time, which is an +// unusual X-Ray pattern not every consumer of this module will want on by default. +function instrumentGithubLatency(githubCreatedAt: string): void { + if (process.env.WEBHOOK_XRAY_GITHUB_LATENCY_ENABLED !== 'true') return; + + const segment = tracer.getSegment(); + if (!segment) return; + + const createdAtMs = new Date(githubCreatedAt).getTime(); + const lagMs = Date.now() - createdAtMs; + + tracer.putAnnotation('event_lag_ms', lagMs); + + const githubNode = segment.addNewSubsegment('github'); + githubNode.namespace = 'remote'; + githubNode.start_time = createdAtMs / 1000; // X-Ray uses epoch seconds + githubNode.addAnnotation('workflow_job_created_at', githubCreatedAt); + githubNode.addAnnotation('event_lag_ms', lagMs); + githubNode.close(); +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export function checkBodySize(body: string, headers: IncomingHttpHeaders): { sizeExceeded: boolean; message: any } { // GitHub does not specify if the content length is always present, fallback to the body size calculation. From d41272fbf259df1b120486dc4b3baf7706eb0374 Mon Sep 17 00:00:00 2001 From: Vishal Wadhera <30888002+wadherv@users.noreply.github.com> Date: Tue, 25 Aug 2026 09:08:42 +0530 Subject: [PATCH 12/13] Update main.tf --- main.tf | 450 ++++++++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 403 insertions(+), 47 deletions(-) diff --git a/main.tf b/main.tf index 547dc7d197..43bd3a293b 100644 --- a/main.tf +++ b/main.tf @@ -1,66 +1,422 @@ locals { - webhook_endpoint = "webhook" - role_path = var.role_path == null ? "/${var.prefix}/" : var.role_path + tags = merge(var.tags, { + "ghr:environment" = var.prefix + }) + + primary_app_id = coalesce(var.github_app.id_ssm, module.ssm.parameters.github_app_id) + primary_app_key_base64 = coalesce(var.github_app.key_base64_ssm, module.ssm.parameters.github_app_key_base64) + + github_app_parameters = { + id = concat( + [local.primary_app_id], + [for p in module.ssm.additional_app_parameters : p.id] + ) + key_base64 = concat( + [local.primary_app_key_base64], + [for p in module.ssm.additional_app_parameters : p.key_base64] + ) + installation_id = concat( + [null], + [for p in module.ssm.additional_app_parameters : p.installation_id] + ) + webhook_secret = coalesce(var.github_app.webhook_secret_ssm, module.ssm.parameters.github_app_webhook_secret) + } + + default_runner_labels = distinct(concat(["self-hosted", var.runner_os, var.runner_architecture])) + runner_labels = (var.runner_disable_default_labels == false) ? sort(concat(local.default_runner_labels, var.runner_extra_labels)) : var.runner_extra_labels + + ssm_root_path = var.ssm_paths.use_prefix ? "/${var.ssm_paths.root}/${var.prefix}" : "/${var.ssm_paths.root}" } -resource "aws_apigatewayv2_api" "webhook" { - name = "${var.prefix}-github-action-webhook" - protocol_type = "HTTP" - tags = var.tags +resource "random_string" "random" { + length = 24 + special = false + upper = false } -resource "aws_apigatewayv2_route" "webhook" { - api_id = aws_apigatewayv2_api.webhook.id - route_key = "POST /${local.webhook_endpoint}" - target = "integrations/${aws_apigatewayv2_integration.webhook.id}" - - lifecycle { - ignore_changes = [ - # Ignore authorization related attributes to enable authenticator assignment to API route. - # NOTE: We consider the ignores as a system internal. Future changes will not trigger a breakig change. - authorizer_id, - authorization_type, - authorization_scopes, +data "aws_iam_policy_document" "deny_insecure_transport" { + statement { + sid = "DenyInsecureTransport" + + effect = "Deny" + + principals { + type = "AWS" + identifiers = ["*"] + } + + actions = [ + "sqs:*" + ] + + resources = [ + "*" ] + + condition { + test = "Bool" + variable = "aws:SecureTransport" + values = ["false"] + } } } -resource "aws_apigatewayv2_stage" "webhook" { - lifecycle { - ignore_changes = [ - # see bug https://github.com/terraform-providers/terraform-provider-aws/issues/12893 - default_route_settings, - # not terraform managed - deployment_id - ] +resource "aws_sqs_queue_policy" "build_queue_policy" { + queue_url = aws_sqs_queue.queued_builds.id + policy = data.aws_iam_policy_document.deny_insecure_transport.json +} + +resource "aws_sqs_queue" "queued_builds" { + name = "${var.prefix}-queued-builds" + delay_seconds = var.delay_webhook_event + visibility_timeout_seconds = var.runners_scale_up_lambda_timeout + message_retention_seconds = var.job_queue_retention_in_seconds + receive_wait_time_seconds = 0 + redrive_policy = var.redrive_build_queue.enabled ? jsonencode({ + deadLetterTargetArn = aws_sqs_queue.queued_builds_dlq[0].arn, + maxReceiveCount = var.redrive_build_queue.maxReceiveCount + }) : null + + sqs_managed_sse_enabled = var.queue_encryption.sqs_managed_sse_enabled + kms_master_key_id = var.queue_encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = var.queue_encryption.kms_data_key_reuse_period_seconds + + tags = var.tags +} + +resource "aws_sqs_queue_policy" "build_queue_dlq_policy" { + count = var.redrive_build_queue.enabled ? 1 : 0 + queue_url = aws_sqs_queue.queued_builds.id + policy = data.aws_iam_policy_document.deny_insecure_transport.json +} + +resource "aws_sqs_queue" "queued_builds_dlq" { + count = var.redrive_build_queue.enabled ? 1 : 0 + name = "${var.prefix}-queued-builds_dead_letter" + + sqs_managed_sse_enabled = var.queue_encryption.sqs_managed_sse_enabled + kms_master_key_id = var.queue_encryption.kms_master_key_id + kms_data_key_reuse_period_seconds = var.queue_encryption.kms_data_key_reuse_period_seconds + tags = var.tags +} + +module "ssm" { + source = "./modules/ssm" + kms_key_arn = var.kms_key_arn + path_prefix = "${local.ssm_root_path}/${var.ssm_paths.app}" + github_app = var.github_app + additional_github_apps = var.additional_github_apps + tags = local.tags +} + +module "webhook" { + source = "./modules/webhook" + + ssm_paths = { + root = local.ssm_root_path + webhook = var.ssm_paths.webhook } + prefix = var.prefix + tags = local.tags + kms_key_arn = var.kms_key_arn + eventbridge = var.eventbridge - api_id = aws_apigatewayv2_api.webhook.id - name = "$default" - auto_deploy = true - dynamic "access_log_settings" { - for_each = var.webhook_lambda_apigateway_access_log_settings[*] - content { - destination_arn = access_log_settings.value.destination_arn - format = access_log_settings.value.format + runner_matcher_config = { + "${var.prefix}-queued-builds" = { + id : aws_sqs_queue.queued_builds.id + arn : aws_sqs_queue.queued_builds.arn + matcherConfig : { + labelMatchers : [local.runner_labels] + exactMatch : var.enable_runner_workflow_job_labels_check_all + bidirectionalLabelMatch : var.enable_runner_bidirectional_label_match + enableDynamicLabels : var.enable_dynamic_labels + awsDynamicLabelsPolicy : var.aws_dynamic_labels_policy + } } } - tags = var.tags + matcher_config_parameter_store_tier = var.matcher_config_parameter_store_tier + + github_app_parameters = { + webhook_secret = local.github_app_parameters.webhook_secret + } + + lambda_s3_bucket = var.lambda_s3_bucket + webhook_lambda_s3_key = var.webhook_lambda_s3_key + webhook_lambda_s3_object_version = var.webhook_lambda_s3_object_version + webhook_lambda_apigateway_access_log_settings = var.webhook_lambda_apigateway_access_log_settings + lambda_runtime = var.lambda_runtime + lambda_architecture = var.lambda_architecture + lambda_zip = var.webhook_lambda_zip + lambda_memory_size = var.webhook_lambda_memory_size + lambda_timeout = var.webhook_lambda_timeout + lambda_tags = var.lambda_tags + tracing_config = var.tracing_config + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + repository_white_list = var.repository_white_list + queue_selection_strategy = var.queue_selection_strategy + + lambda_subnet_ids = var.lambda_subnet_ids + lambda_security_group_ids = var.lambda_security_group_ids + aws_partition = var.aws_partition + + log_level = var.log_level + webhook_xray_github_latency_enabled = var.webhook_xray_github_latency_enabled } -resource "aws_apigatewayv2_integration" "webhook" { - lifecycle { - ignore_changes = [ - # not terraform managed - passthrough_behavior - ] +module "runners" { + source = "./modules/runners" + + aws_region = var.aws_region + aws_partition = var.aws_partition + vpc_id = var.vpc_id + subnet_ids = var.subnet_ids + prefix = var.prefix + tags = local.tags + iam_overrides = var.iam_overrides + + ssm_paths = { + root = local.ssm_root_path + tokens = "${var.ssm_paths.runners}/tokens" + config = "${var.ssm_paths.runners}/config" + } + + s3_runner_binaries = var.enable_runner_binaries_syncer ? { + arn = module.runner_binaries[0].bucket.arn + id = module.runner_binaries[0].bucket.id + key = module.runner_binaries[0].runner_distribution_object_key + } : null + + runner_os = var.runner_os + instance_types = var.instance_types + instance_target_capacity_type = var.instance_target_capacity_type + instance_allocation_strategy = var.instance_allocation_strategy + instance_type_priorities = var.instance_type_priorities + instance_max_spot_price = var.instance_max_spot_price + block_device_mappings = var.block_device_mappings + + runner_architecture = var.runner_architecture + ami = var.ami + + sqs_build_queue = aws_sqs_queue.queued_builds + github_app_parameters = local.github_app_parameters + enable_organization_runners = var.enable_organization_runners + enable_ephemeral_runners = var.enable_ephemeral_runners + enable_job_queued_check = var.enable_job_queued_check + enable_jit_config = var.enable_jit_config + enable_on_demand_failover_for_errors = var.enable_runner_on_demand_failover_for_errors + scale_errors = var.scale_errors + disable_runner_autoupdate = var.disable_runner_autoupdate + enable_managed_runner_security_group = var.enable_managed_runner_security_group + enable_runner_detailed_monitoring = var.enable_runner_detailed_monitoring + scale_down_schedule_expression = var.scale_down_schedule_expression + minimum_running_time_in_minutes = var.minimum_running_time_in_minutes + runner_boot_time_in_minutes = var.runner_boot_time_in_minutes + runner_disable_default_labels = var.runner_disable_default_labels + runner_labels = local.runner_labels + runner_as_root = var.runner_as_root + runner_run_as = var.runner_run_as + runners_maximum_count = var.runners_maximum_count + idle_config = var.idle_config + enable_ssm_on_runners = var.enable_ssm_on_runners + egress_rules = var.runner_egress_rules + runner_additional_security_group_ids = var.runner_additional_security_group_ids + metadata_options = var.runner_metadata_options + credit_specification = var.runner_credit_specification + cpu_options = var.runner_cpu_options + placement = var.runner_placement + license_specifications = var.runner_license_specifications + use_dedicated_host = var.use_dedicated_host + + enable_runner_binaries_syncer = var.enable_runner_binaries_syncer + lambda_s3_bucket = var.lambda_s3_bucket + runners_lambda_s3_key = var.runners_lambda_s3_key + runners_lambda_s3_object_version = var.runners_lambda_s3_object_version + lambda_runtime = var.lambda_runtime + lambda_architecture = var.lambda_architecture + lambda_event_source_mapping_batch_size = var.lambda_event_source_mapping_batch_size + lambda_event_source_mapping_maximum_batching_window_in_seconds = var.lambda_event_source_mapping_maximum_batching_window_in_seconds + lambda_zip = var.runners_lambda_zip + lambda_scale_up_memory_size = var.runners_scale_up_lambda_memory_size + lambda_scale_down_memory_size = var.runners_scale_down_lambda_memory_size + lambda_timeout_scale_up = var.runners_scale_up_lambda_timeout + lambda_timeout_scale_down = var.runners_scale_down_lambda_timeout + lambda_subnet_ids = var.lambda_subnet_ids + lambda_security_group_ids = var.lambda_security_group_ids + lambda_tags = var.lambda_tags + tracing_config = var.tracing_config + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + enable_cloudwatch_agent = var.enable_cloudwatch_agent + cloudwatch_config = var.cloudwatch_config + runner_log_files = var.runner_log_files + runner_group_name = var.runner_group_name + runner_name_prefix = var.runner_name_prefix + parameter_store_tags = var.parameter_store_tags + + scale_up_reserved_concurrent_executions = var.scale_up_reserved_concurrent_executions + + associate_public_ipv4_address = var.associate_public_ipv4_address + + instance_profile_path = var.instance_profile_path + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + + enable_userdata = var.enable_userdata + enable_user_data_debug_logging = var.enable_user_data_debug_logging_runner + userdata_template = var.userdata_template + userdata_content = var.userdata_content + userdata_pre_install = var.userdata_pre_install + userdata_post_install = var.userdata_post_install + runner_hook_job_started = var.runner_hook_job_started + runner_hook_job_completed = var.runner_hook_job_completed + key_name = var.key_name + runner_ec2_tags = var.runner_ec2_tags + + create_service_linked_role_spot = var.create_service_linked_role_spot + + runner_iam_role_managed_policy_arns = var.runner_iam_role_managed_policy_arns + + ghes_url = var.ghes_url + ghes_ssl_verify = var.ghes_ssl_verify + user_agent = var.user_agent + + kms_key_arn = var.kms_key_arn + + log_level = var.log_level + + pool_config = var.pool_config + pool_lambda_memory_size = var.pool_lambda_memory_size + pool_lambda_timeout = var.pool_lambda_timeout + pool_runner_owner = var.pool_runner_owner + pool_lambda_reserved_concurrent_executions = var.pool_lambda_reserved_concurrent_executions + pool_include_busy_runners = var.pool_include_busy_runners + + ssm_housekeeper = var.runners_ssm_housekeeper + ebs_optimized = var.runners_ebs_optimized + + metrics = var.metrics + + job_retry = var.job_retry +} + +module "runner_binaries" { + count = var.enable_runner_binaries_syncer ? 1 : 0 + + source = "./modules/runner-binaries-syncer" + + prefix = var.prefix + tags = local.tags + + distribution_bucket_name = lower("${var.prefix}-dist-${random_string.random.result}") + s3_logging_bucket = var.runner_binaries_s3_logging_bucket + s3_logging_bucket_prefix = var.runner_binaries_s3_logging_bucket_prefix + + runner_os = var.runner_os + runner_architecture = var.runner_architecture + + lambda_s3_bucket = var.lambda_s3_bucket + syncer_lambda_s3_key = var.syncer_lambda_s3_key + syncer_lambda_s3_object_version = var.syncer_lambda_s3_object_version + lambda_runtime = var.lambda_runtime + lambda_architecture = var.lambda_architecture + lambda_zip = var.runner_binaries_syncer_lambda_zip + lambda_memory_size = var.runner_binaries_syncer_lambda_memory_size + lambda_timeout = var.runner_binaries_syncer_lambda_timeout + lambda_tags = var.lambda_tags + tracing_config = var.tracing_config + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + + state_event_rule_binaries_syncer = var.state_event_rule_binaries_syncer + server_side_encryption_configuration = var.runner_binaries_s3_sse_configuration + s3_tags = var.runner_binaries_s3_tags + s3_versioning = var.runner_binaries_s3_versioning + + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + + log_level = var.log_level + + lambda_subnet_ids = var.lambda_subnet_ids + lambda_security_group_ids = var.lambda_security_group_ids + aws_partition = var.aws_partition + + lambda_principals = var.lambda_principals +} + +module "ami_housekeeper" { + count = var.enable_ami_housekeeper ? 1 : 0 + source = "./modules/ami-housekeeper" + + prefix = var.prefix + tags = local.tags + aws_partition = var.aws_partition + + lambda_zip = var.ami_housekeeper_lambda_zip + lambda_s3_bucket = var.lambda_s3_bucket + lambda_s3_key = var.ami_housekeeper_lambda_s3_key + lambda_s3_object_version = var.ami_housekeeper_lambda_s3_object_version + + lambda_architecture = var.lambda_architecture + lambda_principals = var.lambda_principals + lambda_runtime = var.lambda_runtime + lambda_security_group_ids = var.lambda_security_group_ids + lambda_subnet_ids = var.lambda_subnet_ids + lambda_timeout = var.ami_housekeeper_lambda_timeout + lambda_tags = var.lambda_tags + tracing_config = var.tracing_config + + logging_retention_in_days = var.logging_retention_in_days + logging_kms_key_id = var.logging_kms_key_id + log_class = var.log_class + log_level = var.log_level + + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + + cleanup_config = var.ami_housekeeper_cleanup_config + lambda_schedule_expression = var.ami_housekeeper_lambda_schedule_expression +} + +locals { + lambda_instance_termination_watcher = { + prefix = var.prefix + tags = local.tags + aws_partition = var.aws_partition + architecture = var.lambda_architecture + principals = var.lambda_principals + runtime = var.lambda_runtime + security_group_ids = var.lambda_security_group_ids + subnet_ids = var.lambda_subnet_ids + lambda_tags = var.lambda_tags + log_level = var.log_level + log_class = var.log_class + logging_kms_key_id = var.logging_kms_key_id + logging_retention_in_days = var.logging_retention_in_days + role_path = var.role_path + role_permissions_boundary = var.role_permissions_boundary + s3_bucket = var.lambda_s3_bucket + tracing_config = var.tracing_config + metrics = var.metrics + enable_runner_deregistration = var.instance_termination_watcher.enable_runner_deregistration + github_app_parameters = var.instance_termination_watcher.enable_runner_deregistration ? { + id = local.github_app_parameters.id[0] + key_base64 = local.github_app_parameters.key_base64[0] + } : null + ghes_url = var.ghes_url } +} - api_id = aws_apigatewayv2_api.webhook.id - integration_type = "AWS_PROXY" +module "instance_termination_watcher" { + source = "./modules/termination-watcher" + count = var.instance_termination_watcher.enable ? 1 : 0 - connection_type = "INTERNET" - description = "GitHub App webhook for receiving build events." - integration_method = "POST" - integration_uri = !var.eventbridge.enable ? module.direct[0].webhook.lambda.invoke_arn : module.eventbridge[0].webhook.lambda.invoke_arn + config = merge(local.lambda_instance_termination_watcher, var.instance_termination_watcher) } From faf3f533cc714e18941c4aede57ab191b299950c Mon Sep 17 00:00:00 2001 From: Vishal Wadhera Date: Wed, 9 Sep 2026 05:12:32 +0530 Subject: [PATCH 13/13] fix --- lambdas/functions/webhook/src/lambda.ts | 13 ++- .../githubEventTracingMiddleware.test.ts | 86 +++++++++++++++++++ .../tracing/githubEventTracingMiddleware.ts | 62 +++++++++++++ .../functions/webhook/src/webhook/index.ts | 27 +----- main.tf | 3 +- modules/multi-runner/variables.tf | 6 -- modules/multi-runner/webhook.tf | 3 +- modules/webhook/direct/variables.tf | 1 - modules/webhook/direct/webhook.tf | 1 - modules/webhook/eventbridge/variables.tf | 3 +- modules/webhook/eventbridge/webhook.tf | 1 - modules/webhook/variables.tf | 6 -- modules/webhook/webhook.tf | 4 +- variables.tf | 6 -- 14 files changed, 163 insertions(+), 59 deletions(-) create mode 100644 lambdas/functions/webhook/src/tracing/githubEventTracingMiddleware.test.ts create mode 100644 lambdas/functions/webhook/src/tracing/githubEventTracingMiddleware.ts diff --git a/lambdas/functions/webhook/src/lambda.ts b/lambdas/functions/webhook/src/lambda.ts index 1f7cb0c830..5a8c1e8f25 100644 --- a/lambdas/functions/webhook/src/lambda.ts +++ b/lambdas/functions/webhook/src/lambda.ts @@ -9,15 +9,22 @@ import { EventWrapper } from './types'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; import { ConfigDispatcher, ConfigWebhook, ConfigWebhookEventBridge } from './ConfigLoader'; import { dispatch } from './runners/dispatch'; +import { githubEventTracingMiddleware } from './tracing/githubEventTracingMiddleware'; export interface Response { statusCode: number; body: string; } -middy(directWebhook).use(captureLambdaHandler(tracer)); +export const directWebhook = middy(directWebhookHandler) + .use(captureLambdaHandler(tracer)) + .use(githubEventTracingMiddleware()); -export async function directWebhook(event: APIGatewayEvent, context: Context): Promise { +export const eventBridgeWebhook = middy(eventBridgeWebhookHandler) + .use(captureLambdaHandler(tracer)) + .use(githubEventTracingMiddleware()); + +async function directWebhookHandler(event: APIGatewayEvent, context: Context): Promise { setContext(context, 'lambda.ts'); logger.logEventIfEnabled(event); @@ -42,7 +49,7 @@ export async function directWebhook(event: APIGatewayEvent, context: Context): P return result; } -export async function eventBridgeWebhook(event: APIGatewayEvent, context: Context): Promise { +async function eventBridgeWebhookHandler(event: APIGatewayEvent, context: Context): Promise { setContext(context, 'lambda.ts'); logger.logEventIfEnabled(event); diff --git a/lambdas/functions/webhook/src/tracing/githubEventTracingMiddleware.test.ts b/lambdas/functions/webhook/src/tracing/githubEventTracingMiddleware.test.ts new file mode 100644 index 0000000000..b7886eb309 --- /dev/null +++ b/lambdas/functions/webhook/src/tracing/githubEventTracingMiddleware.test.ts @@ -0,0 +1,86 @@ +import { tracer } from '@aws-github-runner/aws-powertools-util'; +import { APIGatewayEvent, Context } from 'aws-lambda'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { githubEventTracingMiddleware } from './githubEventTracingMiddleware'; + +describe('githubEventTracingMiddleware', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + function buildEvent(overrides: Partial = {}): APIGatewayEvent { + return { + headers: { + 'X-GitHub-Event': 'workflow_job', + 'X-GitHub-Delivery': 'delivery-id-1', + }, + body: JSON.stringify({ workflow_job: { created_at: new Date(Date.now() - 5000).toISOString() } }), + requestContext: { requestTimeEpoch: Date.now() - 20 }, + ...overrides, + } as unknown as APIGatewayEvent; + } + + it('does nothing when tracing is not enabled (no active segment)', async () => { + vi.spyOn(tracer, 'getSegment').mockReturnValue(undefined); + const putAnnotation = vi.spyOn(tracer, 'putAnnotation'); + + const { before, after } = githubEventTracingMiddleware(); + await before?.({ event: buildEvent(), context: {} as Context } as never); + await after?.({ event: buildEvent(), context: {} as Context } as never); + + expect(putAnnotation).not.toHaveBeenCalled(); + }); + + it('annotates event type, delivery id, ingress lag and workflow_job age when tracing is active', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(tracer, 'getSegment').mockReturnValue({} as any); + const putAnnotation = vi.spyOn(tracer, 'putAnnotation').mockImplementation(() => undefined); + + const { before } = githubEventTracingMiddleware(); + await before?.({ event: buildEvent(), context: {} as Context } as never); + + expect(putAnnotation).toHaveBeenCalledWith('github_event_type', 'workflow_job'); + expect(putAnnotation).toHaveBeenCalledWith('github_delivery_id', 'delivery-id-1'); + expect(putAnnotation).toHaveBeenCalledWith('api_gateway_ingress_to_lambda_ms', expect.any(Number)); + expect(putAnnotation).toHaveBeenCalledWith('workflow_job_age_ms', expect.any(Number)); + }); + + it('skips workflow_job_age_ms for non workflow_job event types', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(tracer, 'getSegment').mockReturnValue({} as any); + const putAnnotation = vi.spyOn(tracer, 'putAnnotation').mockImplementation(() => undefined); + + const { before } = githubEventTracingMiddleware(); + await before?.({ + event: buildEvent({ headers: { 'X-GitHub-Event': 'push', 'X-GitHub-Delivery': 'delivery-id-2' } }), + context: {} as Context, + } as never); + + expect(putAnnotation).toHaveBeenCalledWith('github_event_type', 'push'); + expect(putAnnotation).not.toHaveBeenCalledWith('workflow_job_age_ms', expect.any(Number)); + }); + + it('does not throw on a malformed body and skips the age annotation', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(tracer, 'getSegment').mockReturnValue({} as any); + const putAnnotation = vi.spyOn(tracer, 'putAnnotation').mockImplementation(() => undefined); + + const { before } = githubEventTracingMiddleware(); + await before?.({ event: buildEvent({ body: 'not-json' }), context: {} as Context } as never); + + expect(putAnnotation).not.toHaveBeenCalledWith('workflow_job_age_ms', expect.any(Number)); + }); + + it('adds lambda_processing_ms annotation on after and onError', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(tracer, 'getSegment').mockReturnValue({} as any); + const putAnnotation = vi.spyOn(tracer, 'putAnnotation').mockImplementation(() => undefined); + + const middleware = githubEventTracingMiddleware(); + await middleware.before?.({ event: buildEvent(), context: {} as Context } as never); + await middleware.after?.({ event: buildEvent(), context: {} as Context } as never); + + expect(putAnnotation).toHaveBeenCalledWith('lambda_processing_ms', expect.any(Number)); + }); +}); diff --git a/lambdas/functions/webhook/src/tracing/githubEventTracingMiddleware.ts b/lambdas/functions/webhook/src/tracing/githubEventTracingMiddleware.ts new file mode 100644 index 0000000000..929312ac4e --- /dev/null +++ b/lambdas/functions/webhook/src/tracing/githubEventTracingMiddleware.ts @@ -0,0 +1,62 @@ +import { MiddlewareObj } from '@middy/core'; +import { APIGatewayEvent, Context } from 'aws-lambda'; +import { tracer } from '@aws-github-runner/aws-powertools-util'; + +// Reusable X-Ray instrumentation for the webhook Lambda's GitHub-originated event types. +// Adds annotations (queryable/alertable) rather than synthetic subsegments, so it never +// alters the shape of the actual X-Ray trace for this invocation. +export function githubEventTracingMiddleware(): MiddlewareObj { + let lambdaStartedAt: number; + + const before = (request: { event: APIGatewayEvent; context: Context }): void => { + lambdaStartedAt = Date.now(); + + if (!tracer.getSegment()) return; + + const headers = lowerCaseKeys(request.event.headers as Record); + const eventType = headers['x-github-event']; + const deliveryId = headers['x-github-delivery']; + + if (eventType !== undefined) tracer.putAnnotation('github_event_type', eventType); + if (deliveryId !== undefined) tracer.putAnnotation('github_delivery_id', deliveryId); + + const apiGatewayIngressAtMs = request.event.requestContext?.requestTimeEpoch; + if (apiGatewayIngressAtMs !== undefined) { + tracer.putAnnotation('api_gateway_ingress_to_lambda_ms', lambdaStartedAt - apiGatewayIngressAtMs); + } + + if (eventType === 'workflow_job') { + const workflowJobAgeMs = tryGetWorkflowJobAgeMs(request.event.body, lambdaStartedAt); + if (workflowJobAgeMs !== undefined) { + tracer.putAnnotation('workflow_job_age_ms', workflowJobAgeMs); + } + } + }; + + const after = (): void => { + if (!tracer.getSegment()) return; + tracer.putAnnotation('lambda_processing_ms', Date.now() - lambdaStartedAt); + }; + + return { before, after, onError: after }; +} + +function lowerCaseKeys(headers: Record): Record { + const result: Record = {}; + for (const key in headers) { + result[key.toLowerCase()] = headers[key]; + } + return result; +} + +// Best-effort: the body isn't verified/parsed yet at this point in the request lifecycle, +// so a malformed or not-yet-signature-verified payload must not fail the request. +function tryGetWorkflowJobAgeMs(body: string | null, nowMs: number): number | undefined { + if (!body) return undefined; + try { + const createdAt = (JSON.parse(body) as { workflow_job?: { created_at?: string } }).workflow_job?.created_at; + return createdAt ? nowMs - new Date(createdAt).getTime() : undefined; + } catch { + return undefined; + } +} diff --git a/lambdas/functions/webhook/src/webhook/index.ts b/lambdas/functions/webhook/src/webhook/index.ts index d337ad0a43..68c500006a 100644 --- a/lambdas/functions/webhook/src/webhook/index.ts +++ b/lambdas/functions/webhook/src/webhook/index.ts @@ -1,6 +1,6 @@ import { Webhooks } from '@octokit/webhooks'; import { WorkflowJobEvent } from '@octokit/webhooks-types'; -import { createChildLogger, tracer } from '@aws-github-runner/aws-powertools-util'; +import { createChildLogger } from '@aws-github-runner/aws-powertools-util'; import { IncomingHttpHeaders } from 'http'; import { Response } from '../lambda'; @@ -164,34 +164,9 @@ function readWorkflowJobEvent( }, }); - instrumentGithubLatency(event.workflow_job.created_at); - return { event, eventType }; } -// Adds X-Ray visibility into the delay between GitHub creating the event and this Lambda -// processing it. Disabled by default; enable via WEBHOOK_XRAY_GITHUB_LATENCY_ENABLED since -// the synthetic 'github' subsegment intentionally backdates its start_time, which is an -// unusual X-Ray pattern not every consumer of this module will want on by default. -function instrumentGithubLatency(githubCreatedAt: string): void { - if (process.env.WEBHOOK_XRAY_GITHUB_LATENCY_ENABLED !== 'true') return; - - const segment = tracer.getSegment(); - if (!segment) return; - - const createdAtMs = new Date(githubCreatedAt).getTime(); - const lagMs = Date.now() - createdAtMs; - - tracer.putAnnotation('event_lag_ms', lagMs); - - const githubNode = segment.addNewSubsegment('github'); - githubNode.namespace = 'remote'; - githubNode.start_time = createdAtMs / 1000; // X-Ray uses epoch seconds - githubNode.addAnnotation('workflow_job_created_at', githubCreatedAt); - githubNode.addAnnotation('event_lag_ms', lagMs); - githubNode.close(); -} - // eslint-disable-next-line @typescript-eslint/no-explicit-any export function checkBodySize(body: string, headers: IncomingHttpHeaders): { sizeExceeded: boolean; message: any } { // GitHub does not specify if the content length is always present, fallback to the body size calculation. diff --git a/main.tf b/main.tf index 43bd3a293b..cad9b66c58 100644 --- a/main.tf +++ b/main.tf @@ -164,8 +164,7 @@ module "webhook" { lambda_security_group_ids = var.lambda_security_group_ids aws_partition = var.aws_partition - log_level = var.log_level - webhook_xray_github_latency_enabled = var.webhook_xray_github_latency_enabled + log_level = var.log_level } module "runners" { diff --git a/modules/multi-runner/variables.tf b/modules/multi-runner/variables.tf index 48fa07fc1a..a47cd2a83c 100644 --- a/modules/multi-runner/variables.tf +++ b/modules/multi-runner/variables.tf @@ -368,12 +368,6 @@ variable "webhook_lambda_timeout" { default = 10 } -variable "webhook_xray_github_latency_enabled" { - description = "Add X-Ray instrumentation (an annotation plus a synthetic 'github' node) measuring the delay between a GitHub workflow_job event's created_at timestamp and this Lambda's invocation. Disabled by default since the synthetic node intentionally backdates its X-Ray start_time, an unusual tracing pattern." - type = bool - default = false -} - variable "role_permissions_boundary" { description = "Permissions boundary that will be added to the created role for the lambda." type = string diff --git a/modules/multi-runner/webhook.tf b/modules/multi-runner/webhook.tf index d4066df648..6ee9b4b2ec 100644 --- a/modules/multi-runner/webhook.tf +++ b/modules/multi-runner/webhook.tf @@ -40,6 +40,5 @@ module "webhook" { lambda_security_group_ids = var.lambda_security_group_ids aws_partition = var.aws_partition - log_level = var.log_level - webhook_xray_github_latency_enabled = var.webhook_xray_github_latency_enabled + log_level = var.log_level } diff --git a/modules/webhook/direct/variables.tf b/modules/webhook/direct/variables.tf index 62e35341e6..402ac514b4 100644 --- a/modules/webhook/direct/variables.tf +++ b/modules/webhook/direct/variables.tf @@ -48,6 +48,5 @@ variable "config" { arn = string version = string })) - webhook_xray_github_latency_enabled = optional(bool, false) }) } diff --git a/modules/webhook/direct/webhook.tf b/modules/webhook/direct/webhook.tf index 5c05fd7a00..dd5548acf6 100644 --- a/modules/webhook/direct/webhook.tf +++ b/modules/webhook/direct/webhook.tf @@ -29,7 +29,6 @@ resource "aws_lambda_function" "webhook" { QUEUE_SELECTION_STRATEGY = var.config.queue_selection_strategy PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) PARAMETER_RUNNER_MATCHER_VERSION = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.version]) # enforce cold start after Changes in SSM parameter - WEBHOOK_XRAY_GITHUB_LATENCY_ENABLED = var.config.webhook_xray_github_latency_enabled } : k => v if v != null } } diff --git a/modules/webhook/eventbridge/variables.tf b/modules/webhook/eventbridge/variables.tf index cb13170a58..c6d35d82d3 100644 --- a/modules/webhook/eventbridge/variables.tf +++ b/modules/webhook/eventbridge/variables.tf @@ -48,7 +48,6 @@ variable "config" { arn = string version = string })) - accept_events = optional(list(string), null) - webhook_xray_github_latency_enabled = optional(bool, false) + accept_events = optional(list(string), null) }) } diff --git a/modules/webhook/eventbridge/webhook.tf b/modules/webhook/eventbridge/webhook.tf index 56981c5ed5..65e6b11e8e 100644 --- a/modules/webhook/eventbridge/webhook.tf +++ b/modules/webhook/eventbridge/webhook.tf @@ -32,7 +32,6 @@ resource "aws_lambda_function" "webhook" { EVENT_BUS_NAME = aws_cloudwatch_event_bus.main.name PARAMETER_GITHUB_APP_WEBHOOK_SECRET = var.config.github_app_parameters.webhook_secret.name PARAMETER_RUNNER_MATCHER_CONFIG_PATH = join(":", [for p in var.config.ssm_parameter_runner_matcher_config : p.name]) - WEBHOOK_XRAY_GITHUB_LATENCY_ENABLED = var.config.webhook_xray_github_latency_enabled } : k => v if v != null } } diff --git a/modules/webhook/variables.tf b/modules/webhook/variables.tf index 9c4f151362..2e5fafd205 100644 --- a/modules/webhook/variables.tf +++ b/modules/webhook/variables.tf @@ -246,9 +246,3 @@ EOF accept_events = optional(list(string), null) }) } - -variable "webhook_xray_github_latency_enabled" { - description = "Add X-Ray instrumentation (an annotation plus a synthetic 'github' node) measuring the delay between a GitHub workflow_job event's created_at timestamp and this Lambda's invocation. Disabled by default since the synthetic node intentionally backdates its X-Ray start_time, an unusual tracing pattern." - type = bool - default = false -} diff --git a/modules/webhook/webhook.tf b/modules/webhook/webhook.tf index d1011d2cb8..1c377bbcc0 100644 --- a/modules/webhook/webhook.tf +++ b/modules/webhook/webhook.tf @@ -92,7 +92,6 @@ module "direct" { version = p.version } ] - webhook_xray_github_latency_enabled = var.webhook_xray_github_latency_enabled } } @@ -136,8 +135,7 @@ module "eventbridge" { version = p.version } ] - accept_events = var.eventbridge.accept_events - webhook_xray_github_latency_enabled = var.webhook_xray_github_latency_enabled + accept_events = var.eventbridge.accept_events } } diff --git a/variables.tf b/variables.tf index 72bf260ffc..c4e1e9b5cf 100644 --- a/variables.tf +++ b/variables.tf @@ -180,12 +180,6 @@ variable "webhook_lambda_timeout" { default = 10 } -variable "webhook_xray_github_latency_enabled" { - description = "Add X-Ray instrumentation (an annotation plus a synthetic 'github' node) measuring the delay between a GitHub workflow_job event's created_at timestamp and this Lambda's invocation. Disabled by default." - type = bool - default = false -} - variable "runners_lambda_zip" { description = "File location of the lambda zip file for scaling runners." type = string