From c47549e339bdd709c5d4512b4cc08861dacc91ed Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sat, 23 Aug 2025 18:11:05 -0400 Subject: [PATCH 01/23] two steps back --- .claude/commands/ldk/setup.md | 170 ----- .claude/ldk-resources/BUILD_SYSTEM_GUIDE.md | 364 ----------- .claude/ldk-resources/Makefile | 371 ----------- .claude/ldk-resources/README.md | 39 -- .claude/ldk-resources/setup-makefile.sh | 110 ---- .claude/ldk-resources/template.rockspec | 28 - .claude/ldk-resources/version-template.md | 133 ---- .claude/memories/contributing-sync.md | 91 --- .claude/memories/github-actions-security.md | 86 --- .claude/memories/ldk-c-coverage.md | 112 ---- .claude/memories/ldk-code-style.md | 169 ----- .claude/memories/ldk-commands.md | 55 -- .claude/memories/ldk-coverage.md | 112 ---- .claude/memories/ldk-index.md | 36 -- .claude/memories/ldk-memory-conventions.md | 25 - .claude/memories/ldk-project-structure.md | 49 -- .claude/memories/ldk-source-directives.md | 186 ------ .claude/memories/ldk-static-libraries.md | 258 -------- .claude/memories/ldk-task-checklist.md | 57 -- .claude/memories/ldk-technology-stack.md | 143 ----- .claude/memories/ldk-test-guidelines.md | 177 ------ .claude/memories/sentry-test-config.md | 20 - .claude/memories/teal-coding-standards.md | 27 - .github/workflows/test-rockspec.yml | 40 -- .github/workflows/test.yml | 1 - .teal-types.d.tl | 17 - CLAUDE.md | 34 - CONTRIBUTING.md | 279 --------- DISTRIBUTION.md | 146 ----- Makefile | 320 ---------- README.md | 384 +----------- codecov.yml | 2 - docker/nginx/Dockerfile | 24 - docker/nginx/docker-compose.yml | 12 - docker/nginx/nginx.conf | 21 - docker/nginx/test.lua | 43 -- docker/redis/Dockerfile | 19 - docker/redis/docker-compose.yml | 22 - docker/redis/test.lua | 207 ------- example-event.png | Bin 215158 -> 0 bytes examples/logging.lua | 347 ----------- examples/logging_simple.lua | 68 -- examples/love2d/test_modules.lua | 60 -- examples/nginx.lua | 53 -- examples/redis.lua | 39 -- examples/roblox/README.md | 20 +- ...-all-in-one.lua => sentry-all-in-one.luau} | 0 examples/tracing_basic.lua | 115 ---- examples/tracing_client.lua | 218 ------- examples/tracing_server.lua | 280 --------- platforms/lua/http_client.lua | 205 ------ platforms/lua/http_server.lua | 273 -------- platforms/lua/init.lua | 274 -------- platforms/noop/init.lua | 162 ----- scripts/bump-version.ps1 | 2 +- scripts/generate-roblox-all-in-one.sh | 586 ------------------ sentry-0.0.6-1.rockspec | 58 +- spec/baggage_headers_spec.lua | 190 ------ spec/context_filtering_spec.lua | 159 ----- spec/dsn_parsing_spec.lua | 258 -------- spec/logger_spec.lua | 404 ------------ spec/os_detection_spec.lua | 168 ----- spec/performance_tracing_integration_spec.lua | 326 ---------- spec/platform_detection_spec.lua | 192 ------ spec/platforms/love2d/conf.lua | 45 -- .../love2d/https_connectivity_spec.lua | 119 ---- spec/platforms/love2d/love2d_spec.lua | 291 --------- spec/platforms/love2d/main.lua | 373 ----------- spec/platforms/lua/http_integration_spec.lua | 431 ------------- spec/sentry_spec.lua | 116 ---- spec/sentry_trace_headers_spec.lua | 276 --------- spec/trace_propagation_spec.lua | 306 --------- spec/tracing/headers_spec.lua | 344 ---------- spec/tracing/init_spec.lua | 378 ----------- spec/tracing/propagation_spec.lua | 361 ----------- src/sentry/core/auto_transport.tl | 64 -- src/sentry/core/client.tl | 173 ------ src/sentry/core/context.tl | 103 --- src/sentry/core/file_io.tl | 67 -- src/sentry/core/file_transport.tl | 64 -- src/sentry/core/scope.tl | 166 ----- src/sentry/core/test_transport.tl | 34 - src/sentry/core/transport.tl | 20 - src/sentry/init.tl | 150 ----- src/sentry/logger/init.tl | 436 ------------- src/sentry/performance/init.tl | 352 ----------- src/sentry/platform_loader.tl | 22 - src/sentry/platforms/defold/file_io.tl | 77 --- src/sentry/platforms/defold/transport.tl | 71 --- src/sentry/platforms/love2d/context.tl | 27 - src/sentry/platforms/love2d/integration.tl | 174 ------ src/sentry/platforms/love2d/os_detection.tl | 24 - src/sentry/platforms/love2d/transport.tl | 159 ----- src/sentry/platforms/nginx/os_detection.tl | 36 -- src/sentry/platforms/nginx/transport.tl | 75 --- src/sentry/platforms/redis/transport.tl | 66 -- src/sentry/platforms/roblox/context.tl | 25 - src/sentry/platforms/roblox/file_io.tl | 52 -- src/sentry/platforms/roblox/os_detection.tl | 23 - src/sentry/platforms/roblox/transport.tl | 85 --- .../platforms/standard/file_transport.tl | 89 --- src/sentry/platforms/standard/os_detection.tl | 81 --- src/sentry/platforms/standard/transport.tl | 104 ---- src/sentry/platforms/test/transport.tl | 59 -- src/sentry/tracing/headers.tl | 342 ---------- src/sentry/tracing/init.tl | 322 ---------- src/sentry/tracing/propagation.tl | 313 ---------- src/sentry/types.tl | 118 ---- src/sentry/utils.tl | 177 ------ src/sentry/utils/dsn.tl | 110 ---- src/sentry/utils/envelope.tl | 84 --- src/sentry/utils/http.tl | 178 ------ src/sentry/utils/json.tl | 109 ---- src/sentry/utils/os.tl | 36 -- src/sentry/utils/runtime.tl | 122 ---- src/sentry/utils/serialize.tl | 76 --- src/sentry/utils/stacktrace.tl | 204 ------ src/sentry/utils/transport.tl | 56 -- src/sentry/version.tl | 6 - tealdoc.yml | 41 -- tlconfig.lua | 14 - 121 files changed, 11 insertions(+), 17061 deletions(-) delete mode 100644 .claude/commands/ldk/setup.md delete mode 100644 .claude/ldk-resources/BUILD_SYSTEM_GUIDE.md delete mode 100644 .claude/ldk-resources/Makefile delete mode 100644 .claude/ldk-resources/README.md delete mode 100755 .claude/ldk-resources/setup-makefile.sh delete mode 100644 .claude/ldk-resources/template.rockspec delete mode 100644 .claude/ldk-resources/version-template.md delete mode 100644 .claude/memories/contributing-sync.md delete mode 100644 .claude/memories/github-actions-security.md delete mode 100644 .claude/memories/ldk-c-coverage.md delete mode 100644 .claude/memories/ldk-code-style.md delete mode 100644 .claude/memories/ldk-commands.md delete mode 100644 .claude/memories/ldk-coverage.md delete mode 100644 .claude/memories/ldk-index.md delete mode 100644 .claude/memories/ldk-memory-conventions.md delete mode 100644 .claude/memories/ldk-project-structure.md delete mode 100644 .claude/memories/ldk-source-directives.md delete mode 100644 .claude/memories/ldk-static-libraries.md delete mode 100644 .claude/memories/ldk-task-checklist.md delete mode 100644 .claude/memories/ldk-technology-stack.md delete mode 100644 .claude/memories/ldk-test-guidelines.md delete mode 100644 .claude/memories/sentry-test-config.md delete mode 100644 .claude/memories/teal-coding-standards.md delete mode 100644 .github/workflows/test-rockspec.yml delete mode 100644 .teal-types.d.tl delete mode 100644 CLAUDE.md delete mode 100644 CONTRIBUTING.md delete mode 100644 DISTRIBUTION.md delete mode 100644 Makefile delete mode 100644 docker/nginx/Dockerfile delete mode 100644 docker/nginx/docker-compose.yml delete mode 100644 docker/nginx/nginx.conf delete mode 100644 docker/nginx/test.lua delete mode 100644 docker/redis/Dockerfile delete mode 100644 docker/redis/docker-compose.yml delete mode 100644 docker/redis/test.lua delete mode 100644 example-event.png delete mode 100644 examples/logging.lua delete mode 100644 examples/logging_simple.lua delete mode 100644 examples/love2d/test_modules.lua delete mode 100644 examples/nginx.lua delete mode 100644 examples/redis.lua rename examples/roblox/{sentry-all-in-one.lua => sentry-all-in-one.luau} (100%) delete mode 100644 examples/tracing_basic.lua delete mode 100644 examples/tracing_client.lua delete mode 100644 examples/tracing_server.lua delete mode 100644 platforms/lua/http_client.lua delete mode 100644 platforms/lua/http_server.lua delete mode 100644 platforms/lua/init.lua delete mode 100644 platforms/noop/init.lua delete mode 100755 scripts/generate-roblox-all-in-one.sh delete mode 100644 spec/baggage_headers_spec.lua delete mode 100644 spec/context_filtering_spec.lua delete mode 100644 spec/dsn_parsing_spec.lua delete mode 100644 spec/logger_spec.lua delete mode 100644 spec/os_detection_spec.lua delete mode 100644 spec/performance_tracing_integration_spec.lua delete mode 100644 spec/platform_detection_spec.lua delete mode 100644 spec/platforms/love2d/conf.lua delete mode 100644 spec/platforms/love2d/https_connectivity_spec.lua delete mode 100644 spec/platforms/love2d/love2d_spec.lua delete mode 100644 spec/platforms/love2d/main.lua delete mode 100644 spec/platforms/lua/http_integration_spec.lua delete mode 100644 spec/sentry_spec.lua delete mode 100644 spec/sentry_trace_headers_spec.lua delete mode 100644 spec/trace_propagation_spec.lua delete mode 100644 spec/tracing/headers_spec.lua delete mode 100644 spec/tracing/init_spec.lua delete mode 100644 spec/tracing/propagation_spec.lua delete mode 100644 src/sentry/core/auto_transport.tl delete mode 100644 src/sentry/core/client.tl delete mode 100644 src/sentry/core/context.tl delete mode 100644 src/sentry/core/file_io.tl delete mode 100644 src/sentry/core/file_transport.tl delete mode 100644 src/sentry/core/scope.tl delete mode 100644 src/sentry/core/test_transport.tl delete mode 100644 src/sentry/core/transport.tl delete mode 100644 src/sentry/init.tl delete mode 100644 src/sentry/logger/init.tl delete mode 100644 src/sentry/performance/init.tl delete mode 100644 src/sentry/platform_loader.tl delete mode 100644 src/sentry/platforms/defold/file_io.tl delete mode 100644 src/sentry/platforms/defold/transport.tl delete mode 100644 src/sentry/platforms/love2d/context.tl delete mode 100644 src/sentry/platforms/love2d/integration.tl delete mode 100644 src/sentry/platforms/love2d/os_detection.tl delete mode 100644 src/sentry/platforms/love2d/transport.tl delete mode 100644 src/sentry/platforms/nginx/os_detection.tl delete mode 100644 src/sentry/platforms/nginx/transport.tl delete mode 100644 src/sentry/platforms/redis/transport.tl delete mode 100644 src/sentry/platforms/roblox/context.tl delete mode 100644 src/sentry/platforms/roblox/file_io.tl delete mode 100644 src/sentry/platforms/roblox/os_detection.tl delete mode 100644 src/sentry/platforms/roblox/transport.tl delete mode 100644 src/sentry/platforms/standard/file_transport.tl delete mode 100644 src/sentry/platforms/standard/os_detection.tl delete mode 100644 src/sentry/platforms/standard/transport.tl delete mode 100644 src/sentry/platforms/test/transport.tl delete mode 100644 src/sentry/tracing/headers.tl delete mode 100644 src/sentry/tracing/init.tl delete mode 100644 src/sentry/tracing/propagation.tl delete mode 100644 src/sentry/types.tl delete mode 100644 src/sentry/utils.tl delete mode 100644 src/sentry/utils/dsn.tl delete mode 100644 src/sentry/utils/envelope.tl delete mode 100644 src/sentry/utils/http.tl delete mode 100644 src/sentry/utils/json.tl delete mode 100644 src/sentry/utils/os.tl delete mode 100644 src/sentry/utils/runtime.tl delete mode 100644 src/sentry/utils/serialize.tl delete mode 100644 src/sentry/utils/stacktrace.tl delete mode 100644 src/sentry/utils/transport.tl delete mode 100644 src/sentry/version.tl delete mode 100644 tealdoc.yml delete mode 100644 tlconfig.lua diff --git a/.claude/commands/ldk/setup.md b/.claude/commands/ldk/setup.md deleted file mode 100644 index bcdfc81..0000000 --- a/.claude/commands/ldk/setup.md +++ /dev/null @@ -1,170 +0,0 @@ -# Setup Lua C/C++ Build System: $ARGUMENTS - -You will guide the user through setting up a complete Lua C/C++ extension build system for their project. - -## ⚠️ CRITICAL SECURITY AND SAFETY CONSTRAINTS - -**NEVER perform these actions:** -1. **Do NOT edit or modify files in `@.claude/ldk-resources/`** - These are templates that must remain unchanged -2. **Do NOT access files outside the current project directory** - Stay within the working directory -3. **Do NOT use sed/awk to edit template files directly** - Always copy first, then edit the copy -4. **Do NOT run git commands that modify the repository** - No git init, git add, git commit, git push, etc. -5. **Only use READ-ONLY git commands** - git rev-parse, git remote get-url, git config (for reading only) - -**Required workflow for templates:** -1. Copy template file to project directory using `cp @.claude/...` -2. Use Edit tool to modify the copied file (never the original template) - -**Prerequisites**: This command requires templates from the LDK resources directory (installed with this toolkit). - -## Step 1: Determine Package Name - -**If $ARGUMENTS contains a package name:** -1. Extract the package name from $ARGUMENTS -2. Say: "I'll set up build system for package '{package-name}'" -3. Ask: "Continue with this package name? (y/N)" - -**If $ARGUMENTS is empty OR user says no:** -1. Ask: "What's your package name? (e.g., mylib)" -2. Wait for user response and store as PACKAGE_NAME - -## Step 2: Gather Project Information - -**Detect Git status once and store results:** -1. Use Bash to check if current directory is a git repository root: - `[ "$(git rev-parse --show-toplevel 2>/dev/null)" = "$(pwd)" ] && echo "GIT_REPO" || echo "NOT_GIT"` -2. Store the result and proceed based on the detected status - -**If GIT_REPO detected:** -1. Get remote URL: `git remote get-url origin 2>/dev/null || echo "NO_REMOTE"` -2. Get maintainer info (prefer local, fallback to global): - - `git config --local user.name 2>/dev/null || git config --global user.name 2>/dev/null` - - `git config --local user.email 2>/dev/null || git config --global user.email 2>/dev/null` -3. Set maintainer information: - - Use git user.name if available - - Fallback to username part of email if name not available - - Default to "Your Name" if nothing available - - **Note**: Use name only, no email addresses for privacy -4. Use actual remote URL if available, otherwise use placeholder URLs with warning - -**If NOT_GIT detected:** -1. Use placeholder URLs: `https://example.com/[PACKAGE_NAME]` -2. Set maintainer to "Your Name" -3. **IMPORTANT**: Do NOT run any git commands - -## Step 3: Check for Existing Build Files - -Use the LS tool to check for existing build files: -- Check for: Makefile, makemk.lua, rockspecs/*.rockspec, *.rockspec - -**If any build files exist:** -1. List the found files to the user -2. Ask: "Found existing build files. Do you want to overwrite them? This will replace your current build configuration. (y/N)" -3. If user says no, respond: "Setup cancelled. Existing build files preserved." and stop. - -## Step 4: Set Up Build System Files - -### 4.1 Copy Core Files -Use the Bash tool to: -1. Copy makemk.lua: `cp @.claude/ldk-resources/makemk.lua .` -2. Copy Makefile template: `cp @.claude/ldk-resources/Makefile .` -3. Use Edit tool to customize the copied Makefile (substitute {{PACKAGE_NAME_UPPER}} with actual package name) - -### 4.2 Create Rockspec -1. Create rockspecs/ directory -2. Copy template: `cp @.claude/ldk-resources/template.rockspec rockspecs/$PACKAGE_NAME-dev-1.rockspec` -3. Use Edit tool to customize the copied rockspec with substitutions: - - {{PACKAGE_NAME}} → actual package name - - {{REPO_URL}} → determined source URL (from Step 2) - - {{HOMEPAGE_URL}} → determined homepage URL (from Step 2) - - {{MAINTAINER}} → determined maintainer name (from Step 2) - -### 4.3 Generate Smart Summary -**Analyze project context:** -1. Read CLAUDE.md if it exists for project description -2. Check README.md for project overview -3. Examine existing code structure and file names -4. Consider package name patterns and directory structure - -**Generate appropriate summary based on analysis:** -- For web frameworks: "A [lightweight/fast] web framework for Lua" -- For database drivers: "Lua bindings for [database] database" -- For parsers: "A [format] parser/library for Lua" -- For utilities: "[Package name] - [functional description] for Lua" -- Generic fallback: "A Lua library for [inferred purpose]" - -**Present summary to user:** -1. Say: "I've analyzed your project and generated this summary: '[GENERATED_SUMMARY]'" -2. Ask: "Use this summary for your rockspec? (Y/n)" -3. If user accepts, use Edit tool to update the copied rockspec with the generated summary -4. If user declines, ask: "Please provide your preferred package description:" and use Edit tool to update with their input - -## Step 5: Set Up Project Structure - -### 5.1 Check Existing Directories -Use LS tool to check for standard directories and report status: -- ✓ src/ directory exists (for C/C++ extensions) -- ✓ lua/ directory exists (for Lua modules) -- ✓ lib/ directory exists (for static libraries, optional) -- ✓ bin/ directory exists (for command scripts, optional) - -### 5.2 Offer Directory Creation -Ask: "Would you like me to create missing standard directories? (y/N)" - -**If yes:** -1. Create src/ and lua/ directories (core directories) -2. Ask separately: "Create lib/ directory for static libraries? (y/N)" -3. Ask separately: "Create bin/ directory for command scripts? (y/N)" -4. Create requested optional directories - -## Step 6: Verify and Summarize Setup - -**Confirm successful setup:** -1. Use LS to list created build files: Makefile, makemk.lua, rockspecs/ -2. Display package configuration summary: - - Package name - - Rockspec location - - Source URL - - Homepage URL - - Generated/custom summary - -## Step 7: Provide Final Guidance - -**Present completion message:** -"✅ Setup complete! Your build system now supports: -- Pure Lua projects (lua/ directory) -- C/C++ extensions (src/ directory) -- Static libraries (lib/ directory) -- Mixed-language projects -- Automatic compiler selection -- Source file directives (@cflags:, @ldflags:, @reflibs:, etc.) -- Coverage instrumentation - -**Next steps:** -1. **Add your code to the appropriate directories:** - - Lua modules → `lua/` - - C/C++ extensions → `src/` - - Static libraries → `lib/` - - Command scripts → `bin/` - -2. **Use source file directives in C/C++ files for automatic build configuration:** - ```c - //@cflags: -Wall -Werror - //@ldflags: -lm -lpthread - //@reflibs: string util/memory - ``` - -3. **Build and install your package:** - ```bash - luarocks make rockspecs/[PACKAGE_NAME]-dev-1.rockspec - ``` - -4. **For coverage analysis:** - ```bash - [PACKAGE_NAME_UPPER]_COVERAGE=1 luarocks make - ```" - -**Important notes:** -- If you used placeholder URLs, remind the user to update them in the rockspec -- The build system automatically detects C/C++ files and applies source directives -- See the LDK memory files for detailed usage examples diff --git a/.claude/ldk-resources/BUILD_SYSTEM_GUIDE.md b/.claude/ldk-resources/BUILD_SYSTEM_GUIDE.md deleted file mode 100644 index e89adc7..0000000 --- a/.claude/ldk-resources/BUILD_SYSTEM_GUIDE.md +++ /dev/null @@ -1,364 +0,0 @@ -# Lua Package Build System Technical Specification - -**Complete build system for Lua packages supporting pure Lua modules, C/C++ extensions, static libraries, command scripts, and mixed projects with LuaRocks integration and coverage support.** - -## Quick Start - -Use the Claude command for interactive setup: -``` -/commands/ldk/setup [package-name] -``` - -This command will: -- Detect your Git repository status -- Set up the complete build system -- Generate customized configuration files -- Create necessary directory structure - -## System Overview - -### Core Features -- **Complete Lua Package System** - Supports pure Lua modules, C/C++ extensions, static libraries, and command scripts -- **Git Submodule Support** - Automatic initialization and building of git submodules -- **Automatic File Discovery** - Scans `lua/`, `src/`, `lib/`, and `bin/` directories automatically -- **LuaRocks Integration** - Seamless integration with LuaRocks package management -- **Mixed Language Support** - Automatic handling of C and C++ files in same project -- **Static Library Support** - Build and link static libraries from `lib/` directory -- **Source Directives** - In-file configuration for compiler and linker flags -- **Coverage Support** - Build with gcov instrumentation for C/C++ code -- **Module Grouping** - Groups related source files by prefix into single modules -- **Automatic Compiler Selection** - Uses appropriate compiler based on file type - -### Build System Components - -1. **Makefile** - Main build configuration (generated from template) -2. **makemk.lua** - Module discovery and build rule generation script -3. **rockspec** - LuaRocks package specification (generated from template) -4. **mk/modules.mk** - Auto-generated module definitions (created during build) - -## Directory Structure - -The build system expects the following project structure: - -``` -your-project/ -├── lua/ # Pure Lua modules -│ ├── mypackage.lua # Main module (optional) -│ └── mypackage/ # Sub-modules (optional) -│ └── helper.lua -├── src/ # C/C++ extensions -│ ├── core.c # → core.so -│ ├── parser.cpp # → parser.so -│ └── utils/ # Nested modules -│ └── helper.c # → utils/helper.so -├── lib/ # Static libraries (optional) -│ ├── string.c # → libstring.a -│ └── util/ -│ └── memory.c # → libutil_memory.a -└── bin/ # Command scripts (optional) - └── mytool.lua # → executable command -``` - -## Source File Directives - -The build system supports in-file configuration through comment directives: - -```c -// example.c -//@cflags: -Wall -Werror -O2 -//@ldflags: -lm -lpthread -//@cppflags: -DENABLE_FEATURE -//@reflibs: string util/memory -``` - -### Available Directives -- **@cflags:** - Additional C compiler flags -- **@cxxflags:** - Additional C++ compiler flags -- **@cppflags:** - Preprocessor flags (both C and C++) -- **@ldflags:** - Linker flags and external libraries -- **@reflibs:** - Reference internal static libraries from lib/ - -## Git Submodule Support - -The build system provides automatic initialization and building of git submodules for projects that depend on external C/C++ libraries. - -### Submodule Auto-Initialization - -When using git submodules, the build system can automatically initialize them before building your project: - -```bash -# The build system checks for: -# 1. Current directory is git repository root -# 2. .gitmodules file exists -# 3. Uninitialized submodules are present -# Then automatically runs: git submodule update --init --recursive -``` - -### Submodule Building - -To build submodule dependencies, uncomment and customize the relevant lines in your Makefile: - -```makefile -submodule-deps: - # If using git submodules, uncomment the following line to auto-initialize: - $(MAKE) submodule-init - - # Build submodule dependencies with isolated environment: - env -i \ - PATH="$$PATH" \ - HOME="$$HOME" \ - SHELL="$$SHELL" \ - USER="$$USER" \ - LANG="$$LANG" \ - LC_ALL="$$LC_ALL" \ - $(MAKE) -C deps/somelib OPTION=value target.a -``` - -### Environment Isolation - -Submodule builds use environment isolation to prevent interference from parent project variables: - -- **Cleared Variables**: All environment variables are cleared with `env -i` -- **Preserved Variables**: Only essential variables are inherited: - - `PATH` - For finding tools and compilers - - `HOME` - For user configuration - - `SHELL` - For proper shell execution - - `USER` - For user identification - - `LANG`, `LC_ALL` - For proper message localization - -### Example Workflow - -1. **Add Submodule**: - ```bash - git submodule add https://github.com/example/somelib.git deps/somelib - ``` - -2. **Configure Build**: Uncomment and customize the build commands in `submodule-deps` target - -3. **Build Project**: The build system will automatically: - - Initialize submodules if needed - - Build submodule dependencies with isolated environment - - Build main project modules - -### Error Handling - -The build system provides clear error messages for common issues: - -- **Not in git root**: `ERROR: Not in git repository root` -- **Missing .gitmodules**: `ERROR: No .gitmodules file found. Add submodules first with 'git submodule add'` -- **Build failures**: Submodule build errors automatically stop the main build - -## Module Types and Compilation - -### Pure Lua Package -Only `lua/` directory with `.lua` files - no compilation needed - -### C/C++ Extension Package -Source files in `src/` directory: -- **C Files (.c)**: Compiled with `$(CC)` (typically gcc/clang) -- **C++ Files (.cpp)**: Compiled with `$(CXX)` (typically g++/clang++) -- **Output**: Shared libraries (.so files) installed to Lua C module path - -### Static Libraries -Source files in `lib/` directory: -- Built as `.a` files for internal linking -- Referenced by C/C++ extensions via `@reflibs:` directive -- Support nested directory structure with prefix grouping - -### Mixed Package -Combination of Lua modules, C/C++ extensions, and static libraries - -### Command Scripts -Executable Lua scripts in `bin/` directory - installed to system bin path - -## Module Grouping Rules - -The build system automatically groups related source files: - -### Prefix Grouping -Files with common prefix are grouped into single module: -``` -src/ -├── network.c → All grouped into network.so -├── network_client.c → -└── network_server.cpp → (links with -lstdc++) -``` - -### Directory Modules -Files in subdirectories create namespaced modules: -``` -src/ -└── utils/ - ├── string.c → utils/string.so - └── memory.c → utils/memory.so -``` - -### Static Library Grouping -Similar prefix grouping for static libraries: -``` -lib/ -├── string.c → libstring.a -├── string_ops.c → (grouped into libstring.a) -└── util/ - └── memory.c → libutil_memory.a -``` - -## Build Commands - -### Basic Operations -```bash -# Build and install package -luarocks make rockspecs/[package]-dev-1.rockspec - -# Build with coverage instrumentation -[PACKAGE]_COVERAGE=1 luarocks make - -# Clean build artifacts -make clean - -# Show build configuration -make show-config - -# Initialize git submodules only -make submodule-init - -# Build submodule dependencies only -make submodule-deps -``` - -### Development Workflow -```bash -# Initial setup -/commands/ldk/setup mypackage - -# Add git submodules (if needed) -git submodule add https://github.com/example/library.git deps/library - -# Configure submodule builds in Makefile's submodule-deps target - -# Development cycle -luarocks make # Build and install package (includes submodules) -make clean # Clean artifacts (optional) -``` - -## Coverage Analysis - -Enable coverage instrumentation by setting environment variable: -```bash -MYPACKAGE_COVERAGE=1 luarocks make -``` - -This enables: -- gcov instrumentation for C/C++ code -- Coverage data collection during tests -- Report generation with gcov/lcov tools - -## Template Variables - -The build system uses these template placeholders: - -| Variable | Description | Example | -|----------|-------------|---------| -| `{{PACKAGE_NAME}}` | Package name | mylib | -| `{{PACKAGE_NAME_UPPER}}` | Uppercase for env vars | MYLIB | -| `{{REPO_URL}}` | Repository source URL | https://github.com/user/repo | -| `{{HOMEPAGE_URL}}` | Project homepage | https://github.com/user/repo | -| `{{MAINTAINER}}` | Package maintainer name | John Doe | - -## Requirements - -### System Requirements -- LuaRocks for Lua package management -- GCC or compatible C compiler -- G++ or compatible C++ compiler (for C++ extensions) -- Standard Unix tools (make, install, find, etc.) - -### Compiler Configuration -- **C Compiler**: Uses `$(CC)` from LuaRocks environment -- **C++ Compiler**: Derives from `$(CC)` (gcc→g++, clang→clang++) -- **Flags**: Inherits SDK and platform settings from LuaRocks - -## Advanced Features - -### Cross-Platform Support -- Automatic detection of platform-specific settings -- Proper library extensions (.so, .dll, .dylib) -- Compatible with macOS, Linux, and BSD systems - -### Parallel Builds -Supports parallel compilation with `make -j` for faster builds - -### Dynamic Module Discovery -The makemk.lua script automatically: -- Scans source directories for modules -- Generates build rules dynamically -- Handles complex dependency resolution - -## Manual Setup Reference - -If you need to set up files manually (not recommended), here's the basic process: - -1. Copy template files to project root: - - `.claude/ldk-resources/Makefile` → `Makefile` - - `.claude/ldk-resources/makemk.lua` → `makemk.lua` - - `.claude/ldk-resources/template.rockspec` → `rockspecs/[package]-dev-1.rockspec` - -2. Replace template variables in copied files: - - `{{PACKAGE_NAME}}` with your package name - - `{{PACKAGE_NAME_UPPER}}` with uppercase package name - - `{{REPO_URL}}` with repository URL - - `{{HOMEPAGE_URL}}` with project homepage - - `{{MAINTAINER}}` with maintainer name - -3. Create directory structure as needed: - ```bash - mkdir -p lua src lib bin rockspecs - ``` - -**Note**: Using `/commands/ldk/setup` is strongly recommended as it handles all these steps automatically and correctly. - -## Related Documentation - -- **Memory Files**: `.claude/memories/ldk-*.md` - Development guidelines and patterns -- **Commands**: `.claude/commands/ldk/` - Interactive setup procedures - -## Troubleshooting - -### Common Issues - -**Module not found after installation** -- Ensure `luarocks make` completed successfully -- Check LUA_PATH and LUA_CPATH environment variables - -**C extension compilation fails** -- Verify compiler is installed and accessible -- Check source file directives for syntax errors -- Review compiler output for missing dependencies - -**Static library linking errors** -- Ensure referenced libraries exist in lib/ directory -- Check @reflibs: directive spelling and paths -- Verify library source files compile successfully - -**Coverage build fails** -- Confirm gcov is installed -- Check that package name environment variable is uppercase -- Verify compiler supports coverage flags - -**Git submodule issues** -- Ensure you're in the git repository root directory -- Verify .gitmodules file exists and is properly configured -- Check that submodules are accessible and can be cloned -- Confirm submodule build commands are properly uncommented in Makefile - -**Submodule build fails** -- Check that the submodule's build system works independently -- Verify environment isolation isn't preventing required tools access -- Review submodule documentation for specific build requirements - -### Getting Help - -For issues or questions: -1. Check the memory files in `.claude/memories/` for patterns and guidelines -2. Review this technical specification -3. Use Claude to diagnose and fix issues with your build configuration diff --git a/.claude/ldk-resources/Makefile b/.claude/ldk-resources/Makefile deleted file mode 100644 index 0916fb1..0000000 --- a/.claude/ldk-resources/Makefile +++ /dev/null @@ -1,371 +0,0 @@ -## -# Lua Package Build System with Automatic Module Discovery -## -# -# PURPOSE: -# This Makefile provides an automated build system for Lua packages that -# contain mixed Lua (.lua) and C/C++ (.c/.cpp) modules. It automatically -# discovers source files and builds them into the appropriate Lua modules. -# -# WHY THIS DESIGN: -# - Traditional Makefiles require manual listing of every source file -# - Module names must match directory structure for Lua's require() system -# - C modules need proper luaopen_* function naming conventions -# - Installation paths must be correct for luarocks package management -# -# KEY FEATURES: -# 1. AUTOMATIC DISCOVERY: Scans src/, lua/, bin/ directories automatically -# 2. PREFIX GROUPING: Groups C files by prefix (foo.c + foo_bar.c → single module) -# 3. NESTED MODULES: Supports directory hierarchy (src/util/parser.c → util.parser) -# 4. MIXED LANGUAGES: Handles C, C++, and Lua files in same project -# 5. LUAROCKS INTEGRATION: Designed to work seamlessly with luarocks build system -# 6. COVERAGE SUPPORT: Built-in support for code coverage analysis -# -# BENEFITS: -# - Zero configuration: Just add files to src/, lua/, bin/ and they're built -# - Maintainable: No need to update Makefile when adding/removing source files -# - Consistent: Enforces proper Lua module naming and structure conventions -# - Portable: Works across different platforms via luarocks -# -# USAGE: -# This Makefile is designed to be used exclusively through 'luarocks make'. -# Direct 'make' execution is prevented to ensure proper variable setup. -# -# MODULE VARIABLE DOCUMENTATION: -# The modules.mk file (generated by makemk.lua) creates variables for each C module: -# -# PREFIX GROUPING: makemk.lua groups files by prefix within each directory -# src/foo.c, src/foo_bar.c, src/foo_baz.c → single 'foo' module -# src/bar.c, src/baz.c → separate 'bar' and 'baz' modules -# -# For each module with grouped files, e.g., src/foo.c + src/foo_bar.c + src/foo_baz.c: -# - Path conversion: src/foo -> foo (/ becomes _) -# - Generated variables: -# * foo_SRC = src/foo.c src/foo_bar.c src/foo_baz.c -# * foo_OBJS = $(foo_SRC:.c=.o) # Object files (auto-generated from SRC) -# := $(foo_OBJS:.cpp=.o) # Also handles .cpp files -# * foo_LINKER = $(CC) or $(CXX) # Linker command (CC for C, CXX for C++) -# * foo_LDFLAGS = [flags] # Additional linker flags (e.g., -lstdc++) -# - Lua module: require('.foo') -# -# Nested directory example, e.g., src/foo/bar.c: -# - Path conversion: src/foo/bar -> foo_bar (/ becomes _) -# - Generated variables: -# * foo_bar_SRC = src/foo/bar.c # Single source file in subdirectory -# * foo_bar_OBJS = $(foo_bar_SRC:.c=.o) # Single object file -# * foo_bar_LINKER = $(CC) # Linker command -# * foo_bar_LDFLAGS = # No additional linker flags -# - Lua module: require('.foo.bar') -# -# Nested directory with grouping, e.g., src/foo/qux.c + src/foo/qux_helper.c: -# - Path conversion: src/foo/qux -> foo_qux (/ becomes _) -# - Generated variables (multiple files grouped into single nested module): -# * foo_qux_SRC = src/foo/qux.c src/foo/qux_helper.c -# * foo_qux_OBJS = $(foo_qux_SRC:.c=.o) # Multiple object files -# * foo_qux_LINKER = $(CC) # Linker command -# * foo_qux_LDFLAGS = # No additional linker flags -# - Lua module: require('.foo.qux') -# -# Additionally, modules.mk defines: -# MODULES = src/foo src/foo/bar src/foo/qux # List of all discovered modules -# -# These variables are used by the static pattern rules: -# $(MODULE_TARGETS): %.$(LIB_EXTENSION): $(...) -# -# Variable naming transformations: -# foo.so -> foo_OBJS, foo_LINKER, foo_LDFLAGS -# foo/bar.so -> foo_bar_OBJS, foo_bar_LINKER, foo_bar_LDFLAGS -# foo/qux.so -> foo_qux_OBJS, foo_qux_LINKER, foo_qux_LDFLAGS -# - -# Build flags configuration -# Coverage flags (enabled when {{PACKAGE_NAME_UPPER}}_COVERAGE is set) -ifdef {{PACKAGE_NAME_UPPER}}_COVERAGE -COVFLAGS = --coverage -endif - - -## -# Display build environment information -# These variables are provided by luarocks via rockspec build_variables -## -define DISPLAY_BUILD_ENV -$(info ========================================================================) -$(info Phase 1: Build Environment Setup) -$(info ========================================================================) -$(info External Variables from luarocks:) -# Package name (e.g., "example") -$(info PACKAGE_NAME: $(PACKAGE_NAME)) -# Library extension (e.g., "so" on Linux/macOS, "dll" on Windows) -$(info LIB_EXTENSION: $(LIB_EXTENSION)) -# Lua module installation directory (e.g., /usr/local/share/lua/5.1) -$(info LUADIR: $(LUADIR)) -# C library installation directory (e.g., /usr/local/lib/lua/5.1) -$(info LIBDIR: $(LIBDIR)) -# Binary/script installation directory (e.g., /usr/local/bin) -$(info BINDIR: $(BINDIR)) -# C compiler command (e.g., "gcc", "clang") -$(info CC: $(CC)) -# C++ compiler command (e.g., "g++", "clang++") -$(info CXX (derived): $(CXX)) -# C compiler flags -$(info CFLAGS: $(CFLAGS)) -# C++ compiler flags -$(info CXXFLAGS: $(CXXFLAGS)) -# Linker flags -$(info LDFLAGS: $(LDFLAGS)) -# Platform-specific linker flags -$(info PLATFORM_LDFLAGS: $(PLATFORM_LDFLAGS)) -# Coverage flags (enabled when {{PACKAGE_NAME_UPPER}}_COVERAGE is set) -$(info COVFLAGS: $(COVFLAGS)) -$(info ========================================================================) -endef - -# Display build environment information only when not running clean -ifeq ($(filter clean,$(MAKECMDGOALS)),) -$(eval $(call DISPLAY_BUILD_ENV)) -endif - -## -# External variable validation -## -# Allow 'make clean' to run without PACKAGE_NAME -ifeq ($(filter clean,$(MAKECMDGOALS)),) - ifndef PACKAGE_NAME - $(error This Makefile must be used through 'luarocks make'. Please run 'luarocks make' instead of 'make' directly.) - endif -endif - -# Validate required external variables (skip for clean target) -ifeq ($(filter clean,$(MAKECMDGOALS)),) - $(info Validating required external variables...) - ifndef LIB_EXTENSION - $(error Required variable LIB_EXTENSION is not set. Check rockspec install_variables.) - endif -endif - - -## -# Input file discovery and processing -## -# Discover command files in bin/ directory -ifeq ($(filter clean,$(MAKECMDGOALS)),) -$(info Discovering command scripts in bin/ directory...) -endif -CMD_SOURCES = $(shell find bin -name '*.lua' 2>/dev/null || true) -CMD_TARGETS = $(patsubst bin/%.lua, $(BINDIR)/%, $(CMD_SOURCES)) - -# Discover Lua module files in lua/ directory -ifeq ($(filter clean,$(MAKECMDGOALS)),) -$(info Discovering Lua modules in lua/ directory...) -endif -LUASRCS = $(shell find lua -name '*.lua' 2>/dev/null || true) -LUALIBS = $(patsubst lua/%,$(LUALIBDIR)/%,$(filter-out lua/$(PACKAGE_NAME).lua,$(LUASRCS))) -# Set MAINLIB for either Lua or C main module (but not both) -MAINLIB = $(if $(wildcard lua/$(PACKAGE_NAME).lua),$(LUADIR)/$(PACKAGE_NAME).lua,$(if $(filter $(PACKAGE_NAME),$(MODULE_NAMES)),$(LIBDIR)/$(PACKAGE_NAME).$(LIB_EXTENSION))) - -## -# C module definition generation and processing -# makemk.lua scans src/ directory and groups C/C++ files by prefix to create modules -## -ifeq ($(filter install clean,$(MAKECMDGOALS)),) -$(info ========================================================================) -$(info Phase 2: C Module Discovery and Processing) -$(info ========================================================================) -$(info Scanning src/ directory for C/C++ source files...) -MAKEMK_OUTPUT := $(shell lua makemk.lua >&2 && echo "SUCCESS" || echo "FAILED") -ifneq ($(MAKEMK_OUTPUT),SUCCESS) -$(error makemk.lua failed to generate mk/modules.mk. Check the error messages above.) -endif -endif - -# Include module definitions from mk/modules.mk (skip for clean target) -ifeq ($(filter clean,$(MAKECMDGOALS)),) - $(info Loading generated module definitions from mk/modules.mk...) - -include mk/modules.mk -endif - -# Generate target variables and paths from C module definitions -ifeq ($(filter clean,$(MAKECMDGOALS)),) -$(info Generating build targets and installation paths...) -endif -# MODULE_NAMES: extract just the filename (util, helper) -MODULE_NAMES = $(foreach mod,$(MODULES),$(notdir $(mod))) -# MODULE_TARGETS: convert src/util/helper -> src/util/helper.$(LIB_EXTENSION) (same directory as .o files) -MODULE_TARGETS = $(addsuffix .$(LIB_EXTENSION),$(MODULES)) -# MODULE_LIBS: full installation paths for C libraries (excluding main module if it's C) -MODULE_LIBS = $(patsubst src/%,$(CLIBDIR)/%,$(addsuffix .$(LIB_EXTENSION),$(filter-out src/$(PACKAGE_NAME),$(MODULES)))) - -## -# Configuration and validation -## -ifeq ($(filter clean,$(MAKECMDGOALS)),) -$(info Configuring build environment and validating module setup...) -endif -# Installation directory configuration -# LUALIBDIR - Package-specific Lua library directory ($(LUADIR)/$(PACKAGE_NAME)) -# CLIBDIR - Package-specific C library directory ($(LIBDIR)/$(PACKAGE_NAME)) -LUALIBDIR = $(LUADIR)/$(PACKAGE_NAME) -CLIBDIR = $(LIBDIR)/$(PACKAGE_NAME) - -# Module validation -# Check for main module conflicts - prevent having both lua/package.lua and src/package.c -HAS_MAIN_LUA = $(wildcard lua/$(PACKAGE_NAME).lua) -HAS_MAIN_C = $(filter $(PACKAGE_NAME),$(MODULE_NAMES)) - -ifneq ($(HAS_MAIN_LUA),) -ifneq ($(HAS_MAIN_C),) -$(error Error: Both Lua main module (lua/$(PACKAGE_NAME).lua) and C main module (src/$(PACKAGE_NAME).c) exist. Please use only one main module type.) -endif -endif - -## -# Installation macro -## -# Common installation macro - creates directory and copies file -define INSTALL_FILES - @echo "Installing $< -> $@" - @echo "Creating directory: $(@D)" - @mkdir -p "$(@D)" - @install "$<" "$@" -endef - - -## -# Build targets -## -.PHONY: all submodule-init submodule-deps install install-commands install-lualibs clean show-vars - -# Default target - build all C modules -all: submodule-deps $(MODULE_TARGETS) - - -## -# Git submodule auto-initialization target -## -submodule-init: - @# Check if we're in a git repository root - @if [ "$$(git rev-parse --show-toplevel 2>/dev/null)" != "$$(pwd)" ]; then \ - echo "ERROR: Not in git repository root"; \ - exit 1; \ - fi - @# Check if .gitmodules exists - @if [ ! -f .gitmodules ]; then \ - echo "ERROR: No .gitmodules file found. Add submodules first with 'git submodule add'"; \ - exit 1; \ - fi - @# Check if there are uninitialized submodules - @if [ -z "$$(git submodule status 2>/dev/null | grep '^-' | head -1)" ]; then \ - echo "All submodules already initialized"; \ - exit 0; \ - fi - @echo "========================================================================" - @echo "Git submodules detected but not initialized. Initializing now..." - @echo "========================================================================" - @git submodule update --init --recursive - - -## -# Submodule dependency target -## -submodule-deps: - # - # If using git submodules, uncomment the following line to auto-initialize: - # - # $(MAKE) submodule-init - # - # Build submodule dependencies (User-defined) - # Use env -i to clear environment and only inherit the following variables: - # - # env -i \ - # PATH="$$PATH" \ - # HOME="$$HOME" \ - # SHELL="$$SHELL" \ - # USER="$$USER" \ - # LANG="$$LANG" \ - # LC_ALL="$$LC_ALL" \ - # $(MAKE) -C - -## -# Installation rules -## -# Main module installation (either Lua or C) -ifneq ($(MAINLIB),) -# Install Lua main module if it exists -ifneq ($(wildcard lua/$(PACKAGE_NAME).lua),) -$(MAINLIB): lua/$(PACKAGE_NAME).lua - $(INSTALL_FILES) -else -# Install C main module if it exists -$(MAINLIB): src/$(PACKAGE_NAME).$(LIB_EXTENSION) - $(INSTALL_FILES) -endif -endif - -# C library files installation (src/*.so -> $(CLIBDIR)/*.so) -# Pattern matches entire path structure from build to installation -$(CLIBDIR)/%.$(LIB_EXTENSION): src/%.$(LIB_EXTENSION) - $(INSTALL_FILES) - -# Command script installation (bin/*.lua -> $(BINDIR)/*) -$(BINDIR)/%: bin/%.lua - $(INSTALL_FILES) - @chmod +x $@ - -# Command installation target -install-commands: $(CMD_TARGETS) - -# Lua library files installation (lua/*.lua -> $(LUALIBDIR)/*) -$(LUALIBDIR)/%: lua/% - $(INSTALL_FILES) - -# Lua library installation target -install-lualibs: $(LUALIBS) - -# Main installation target - installs all discovered files -install: submodule-deps install-commands install-lualibs $(MAINLIB) $(MODULE_LIBS) - @echo "" - @echo "DEBUG: Install phase environment variables:" - @echo "LUADIR: $(LUADIR)" - @echo "LIBDIR: $(LIBDIR)" - @echo "BINDIR: $(BINDIR)" - @echo "" - @echo "Installation complete" - @$(MAKE) clean-objects - -## -# Debug and utility targets -## -# Show all build variables - useful for debugging and verification -show-vars: - @echo "=== External Variables ===" - @echo "PACKAGE_NAME: $(PACKAGE_NAME)" - @echo "LIB_EXTENSION: $(LIB_EXTENSION)" - @echo "LUADIR: $(LUADIR)" - @echo "LIBDIR: $(LIBDIR)" - @echo "BINDIR: $(BINDIR)" - @echo "" - @echo "=== Input Files ===" - @echo "CMD_SOURCES: $(CMD_SOURCES)" - @echo "LUASRCS: $(LUASRCS)" - @echo "MODULES: $(MODULES)" - @echo "" - @echo "=== Generated Targets ===" - @echo "CMD_TARGETS: $(CMD_TARGETS)" - @echo "LUALIBS: $(LUALIBS)" - @echo "MAINLIB: $(MAINLIB)" - @echo "MODULE_NAMES: $(MODULE_NAMES)" - @echo "MODULE_TARGETS: $(MODULE_TARGETS)" - @echo "MODULE_LIBS: $(MODULE_LIBS)" - -# Clean target - remove build artifacts -clean-objects: - # Remove all libraries (.so/.dll) and object files - find src \( -name "*.so" -o -name "*.dll" -o -name "*.o" \) -delete 2>/dev/null || true - find lib \( -name "*.a" -o -name "*.o" \) -delete 2>/dev/null || true - -clean: - # Remove all coverage artifacts - find src \( -name "*.gcda" -o -name "*.gcno" \) -delete 2>/dev/null || true - find lib \( -name "*.gcda" -o -name "*.gcno" \) -delete 2>/dev/null || true - @$(MAKE) clean-objects diff --git a/.claude/ldk-resources/README.md b/.claude/ldk-resources/README.md deleted file mode 100644 index 35710dd..0000000 --- a/.claude/ldk-resources/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Lua Resources Directory - -**Template files and configuration resources for Lua development tools.** - -## Purpose - -The `ldk-resources/` directory contains Lua-specific template files, configuration examples, and other resources that can be copied and customized for Lua development tools. These files are referenced by setup commands in the `commands/ldk/` directory. - -## Available Resources - -### Lua Package Build System -- **Files**: `Makefile`, `makemk.lua`, `template.rockspec`, `setup-makefile.sh` -- **Purpose**: Complete Lua package build system supporting pure Lua modules, C/C++ extensions, git submodules, and mixed projects -- **Features**: Automatic file discovery, LuaRocks integration, submodule support, environment isolation -- **Setup**: Use Claude command `/commands/ldk/setup [package] [user]` for setup -- **Documentation**: See `BUILD_SYSTEM_GUIDE.md` for detailed information - -## Design Philosophy - -- **Resource-Focused** - Contains only files and templates -- **Copy-and-Customize** - Templates provide starting points -- **Lua-Specific** - Optimized for Lua development workflows -- **Version-Controlled** - Templates evolve with best practices - -## Usage Pattern - -1. Identify needed resources from available templates -2. Follow setup commands in `commands/ldk/` directory -3. Copy template files to your project -4. Customize templates for your specific project needs -5. Maintain local customizations as needed - -## Relationship to Commands - -- **`commands/ldk/`** - How to use these resources (procedures) -- **`ldk-resources/`** - What to use (template files) -- **`memories/`** - Why and when to use tools (guidelines) - -This separation keeps template files organized and easily accessible while maintaining clear setup procedures. \ No newline at end of file diff --git a/.claude/ldk-resources/setup-makefile.sh b/.claude/ldk-resources/setup-makefile.sh deleted file mode 100755 index 682e0b7..0000000 --- a/.claude/ldk-resources/setup-makefile.sh +++ /dev/null @@ -1,110 +0,0 @@ -#!/bin/bash -# Lua C/C++ Extension Build System Setup Script -# Generates customized Makefile, makemk.lua, and rockspec - -set -e - -# Check arguments -if [ $# -ne 2 ]; then - echo "Usage: $0 " - echo "Example: $0 mylib johndoe" - exit 1 -fi - -PACKAGE_NAME="$1" -GITHUB_USER="$2" - -# Check for help flag -if [[ "$1" == "--help" || "$1" == "-h" ]]; then - echo "Usage: $0 " - echo "Example: $0 mylib johndoe" - echo - echo "Generates customized Lua C/C++ build system files:" - echo " - Makefile with coverage support" - echo " - makemk.lua module discovery script" - echo " - -dev-1.rockspec for LuaRocks" - exit 0 -fi - -# Validate inputs -if [ -z "$PACKAGE_NAME" ]; then - echo "Error: Package name cannot be empty" - exit 1 -fi - -if [ -z "$GITHUB_USER" ]; then - echo "Error: GitHub username cannot be empty" - exit 1 -fi - -# Convert package name to uppercase for coverage flags -PACKAGE_NAME_UPPER=$(echo "$PACKAGE_NAME" | tr '[:lower:]' '[:upper:]') - -echo "=== Lua Makefile Setup ===" -echo "Package name: $PACKAGE_NAME" -echo "GitHub user: $GITHUB_USER" -echo "Coverage flag: ${PACKAGE_NAME_UPPER}_COVERAGE" -echo - -# Check if we're in the template directory -if [ ! -f ".claude/ldk-resources/Makefile" ]; then - echo "Error: .claude/ldk-resources/Makefile not found. Make sure you're in the correct directory." - exit 1 -fi - -# Check for existing build files -EXISTING_FILES="" -[ -f "Makefile" ] && EXISTING_FILES="$EXISTING_FILES Makefile" -[ -f "makemk.lua" ] && EXISTING_FILES="$EXISTING_FILES makemk.lua" -[ -f "rockspecs/$PACKAGE_NAME-dev-1.rockspec" ] && EXISTING_FILES="$EXISTING_FILES rockspecs/$PACKAGE_NAME-dev-1.rockspec" -[ -f "$PACKAGE_NAME-dev-1.rockspec" ] && EXISTING_FILES="$EXISTING_FILES $PACKAGE_NAME-dev-1.rockspec" - -if [ -n "$EXISTING_FILES" ]; then - echo "Warning: Found existing build files:$EXISTING_FILES" - echo "These files will be overwritten." - echo -fi - -echo "Generating files..." - -# Generate customized Makefile -sed -e "s/{{PACKAGE_NAME}}/$PACKAGE_NAME/g" \ - -e "s/{{PACKAGE_NAME_UPPER}}/$PACKAGE_NAME_UPPER/g" \ - .claude/ldk-resources/Makefile > Makefile -echo "✓ Makefile generated" - -# Copy makemk.lua (no customization needed) -cp .claude/ldk-resources/makemk.lua . -echo "✓ makemk.lua copied" - -# Create rockspecs directory if it doesn't exist -mkdir -p rockspecs - -# Generate customized rockspec in rockspecs directory -sed -e "s/{{PACKAGE_NAME}}/$PACKAGE_NAME/g" \ - -e "s/{{GITHUB_USER}}/$GITHUB_USER/g" \ - .claude/ldk-resources/template.rockspec > "rockspecs/$PACKAGE_NAME-dev-1.rockspec" -echo "✓ rockspecs/$PACKAGE_NAME-dev-1.rockspec generated" - -echo -echo "Setup complete! Your C/C++ build system is ready." -echo -echo "Directory structure expected:" -echo " rockspecs/ - Rockspec files (created)" -echo " src/ - C/C++ source files (.c, .cpp)" -echo " lua/ - Lua library files (.lua)" -echo " bin/ - Command scripts (.lua, optional)" -echo -echo "Usage:" -echo " luarocks make rockspecs/$PACKAGE_NAME-dev-1.rockspec # Build and install" -echo " ${PACKAGE_NAME_UPPER}_COVERAGE=1 luarocks make rockspecs/$PACKAGE_NAME-dev-1.rockspec # Build with coverage" -echo -echo "Supports:" -echo " - Pure Lua projects (lua/ only)" -echo " - Pure C projects (.c files)" -echo " - Pure C++ projects (.cpp files)" -echo " - Mixed C/C++ projects" -echo " - Mixed Lua and C/C++ projects" -echo " - Automatic compiler selection" -echo " - File grouping by prefix" -echo " - Coverage instrumentation" diff --git a/.claude/ldk-resources/template.rockspec b/.claude/ldk-resources/template.rockspec deleted file mode 100644 index 572e924..0000000 --- a/.claude/ldk-resources/template.rockspec +++ /dev/null @@ -1,28 +0,0 @@ -package = "{{PACKAGE_NAME}}" -version = "dev-1" -source = {url = "git+{{REPO_URL}}.git"} -description = { - summary = "A Lua package built with claude-lua-devkit", - homepage = "{{HOMEPAGE_URL}}", - license = "MIT/X11", - maintainer = "{{MAINTAINER}}" -} -dependencies = {"lua >= 5.1"} -build = { - type = 'make', - build_variables = { - PACKAGE_NAME = "{{PACKAGE_NAME}}", - LIB_EXTENSION = "$(LIB_EXTENSION)", - CFLAGS = "$(CFLAGS)", - CPPFLAGS = "-I$(LUA_INCDIR)", - LDFLAGS = "$(LIBFLAG)", - WARNINGS = "-Wall -Wno-trigraphs -Wmissing-field-initializers -Wreturn-type -Wmissing-braces -Wparentheses -Wno-switch -Wunused-function -Wunused-label -Wunused-parameter -Wunused-variable -Wunused-value -Wuninitialized -Wunknown-pragmas -Wshadow -Wsign-compare" - }, - install_variables = { - PACKAGE_NAME = "{{PACKAGE_NAME}}", - LIB_EXTENSION = "$(LIB_EXTENSION)", - BINDIR = "$(BINDIR)", - LIBDIR = "$(LIBDIR)", - LUADIR = "$(LUADIR)" - } -} diff --git a/.claude/ldk-resources/version-template.md b/.claude/ldk-resources/version-template.md deleted file mode 100644 index e7699a2..0000000 --- a/.claude/ldk-resources/version-template.md +++ /dev/null @@ -1,133 +0,0 @@ -# Version Management: $ARGUMENTS -# INSTALL_VERSION=development -# INSTALL_COMMIT=HEAD -# INSTALL_DATE=Not installed via INSTALL.md -# REPOSITORY=https://github.com/mah0x211/claude-lua-devkit.git - -You will manage the claude-lua-devkit installation version. - -## Step 1: Parse Arguments - -Parse $ARGUMENTS: -- IF empty or "current": Show current version information -- IF "list" or "ls": Show available remote versions -- IF "update": Update to latest version using remote INSTALL.md -- IF "update [version]": Update to specific version -- IF "help": Show usage information - -## Step 2: Execute Command Based on Arguments - -### Show Current Version (default, "current") - -Extract version information from this command file: -```bash -CURRENT_VERSION=$(grep "^# INSTALL_VERSION=" "$0" | cut -d'=' -f2) -CURRENT_COMMIT=$(grep "^# INSTALL_COMMIT=" "$0" | cut -d'=' -f2) -CURRENT_DATE=$(grep "^# INSTALL_DATE=" "$0" | cut -d'=' -f2-) -CURRENT_REPO=$(grep "^# REPOSITORY=" "$0" | cut -d'=' -f2-) -``` - -Display version information nicely: -``` -claude-lua-devkit Version Information: -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -Version: $CURRENT_VERSION -Commit: $CURRENT_COMMIT -Installed: $CURRENT_DATE -Repository: $CURRENT_REPO -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -``` - -### List Remote Versions ("list", "ls") - -Say: "Fetching available versions from GitHub..." - -```bash -# Create temporary directory for version check -mkdir -p .tmp -git clone --bare https://github.com/mah0x211/claude-lua-devkit.git .tmp/version-check 2>/dev/null -``` - -IF clone successful: -```bash -cd .tmp/version-check -git tag -l 'v*' | sort -V -cd ../.. -rm -rf .tmp/version-check -``` - -Display available versions: -``` -Available claude-lua-devkit versions: -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ -[list of versions] -master (development) -━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ - -To update: /commands/ldk/version update [version] -Example: /commands/ldk/version update v1.0.0 -``` - -IF clone failed: -Say: "Failed to fetch version information. Please check your internet connection." - -### Update Installation ("update", "update [version]") - -IF specific version provided: -- Say: "Updating to version [version]..." -- Set target version for installation - -IF no version provided: -- Say: "Updating to latest version..." -- Set target to master/latest - -Execute update using remote INSTALL.md: -Say: "Executing update using remote installation procedure..." - -```bash -# Fetch and execute remote INSTALL.md -curl -s https://raw.githubusercontent.com/mah0x211/claude-lua-devkit/master/INSTALL.md > .tmp/remote-install.md 2>/dev/null -``` - -IF fetch successful: -- Say: "✓ Downloaded latest installation procedure" -- Say: "Following remote installation steps for update..." -- Execute the installation steps from the remote INSTALL.md -- The remote installer will detect existing installation and handle update - -IF fetch failed: -Say: "Failed to download remote installer. Please check your internet connection or try manual update." - -### Show Help ("help") - -Display usage information: -``` -claude-lua-devkit Version Management - -Usage: /commands/ldk/version [command] - -Commands: - (no args) Show current version information - current Show current version information - list List available remote versions - ls List available remote versions (alias) - update Update to latest version - update Update to specific version (e.g., v1.0.0) - help Show this help message - -Examples: - /commands/ldk/version - /commands/ldk/version list - /commands/ldk/version update - /commands/ldk/version update v1.0.0 - -The update command uses the remote INSTALL.md for safe upgrading. -``` - -## Step 3: Completion Message - -After successful command execution: -- IF showing version: No additional message needed -- IF listing versions: Say "Use '/commands/ldk/version update [version]' to update" -- IF updating: Say "Update complete! Version information updated." -- IF help: No additional message needed \ No newline at end of file diff --git a/.claude/memories/contributing-sync.md b/.claude/memories/contributing-sync.md deleted file mode 100644 index 0d9a51c..0000000 --- a/.claude/memories/contributing-sync.md +++ /dev/null @@ -1,91 +0,0 @@ -# CONTRIBUTING.md and CI Workflow Synchronization - -Critical memory: CONTRIBUTING.md and GitHub Actions workflows must remain synchronized. - -## Sync Requirements - -### Files to Keep in Sync -- `/CONTRIBUTING.md` - Developer documentation -- `/.github/workflows/test.yml` - CI pipeline -- `/Makefile` - Build system (install target) -- `/sentry-*.rockspec` - LuaRocks dependencies - -### Critical Sync Points - -#### System Dependencies -**Ubuntu/Debian:** -```bash -sudo apt-get update -sudo apt-get install -y libssl-dev -``` - -**macOS:** -```bash -brew install openssl -export OPENSSL_DIR=$(brew --prefix openssl) -``` - -**Windows:** -- SSL support built-in -- MSVC build tools (ilammy/msvc-dev-cmd@v1) - setup before Lua installation - -#### Lua Environment -- **Versions**: Lua 5.1, 5.2, 5.3, 5.4, LuaJIT (all supported) -- **Package Manager**: LuaRocks - -#### LuaRocks Dependencies (must match Makefile install target) -1. `busted` - Testing framework -2. `tl` - Teal compiler -3. `lua-cjson` - JSON library -4. `luasocket` - HTTP client -5. `luasec` - SSL/TLS support -6. `luacov` - Code coverage -7. `luacov-reporter-lcov` - Coverage reporting -8. `tealdoc` - Documentation (install-all only) - -#### Build Commands -- `make install` - Install dependencies -- `make build` - Build project -- `make test` - Run tests -- `make coverage-report` - Tests with coverage (Linux/macOS) -- `make clean` - Clean build artifacts - -#### Platform Matrix -- **Ubuntu Latest** - Full testing + coverage + Codecov (Lua 5.4 only) -- **macOS Latest** - Full testing + coverage -- **Windows Latest** - Basic testing only (no coverage) - -#### Lua Version Matrix -- **Lua 5.1, 5.2, 5.3, 5.4** - Standard Lua versions -- **LuaJIT** - Just-in-Time compiler - -## Update Process - -When changing build requirements: - -1. **Update GitHub Actions workflow** (`.github/workflows/test.yml`) -2. **Update CONTRIBUTING.md** to match -3. **Update Makefile** if dependencies change -4. **Update rockspec** if runtime dependencies change -5. **Test locally** on your platform -6. **Verify CI passes** on all platforms - -## Last Sync Verification - -Always verify these elements match across files: -- [ ] System dependency installation commands -- [ ] Lua version requirement -- [ ] LuaRocks package list and versions -- [ ] Make targets used in CI -- [ ] Platform-specific considerations -- [ ] Build and test command sequences - -## Usage in Development - -When updating project requirements: -1. Check this memory file first -2. Update all sync points simultaneously -3. Test the full workflow locally -4. Verify CI pipeline succeeds - -This prevents contributor confusion and CI failures from documentation drift. \ No newline at end of file diff --git a/.claude/memories/github-actions-security.md b/.claude/memories/github-actions-security.md deleted file mode 100644 index 6541b59..0000000 --- a/.claude/memories/github-actions-security.md +++ /dev/null @@ -1,86 +0,0 @@ -# GitHub Actions Security Best Practices - -Critical memory: Always use commit SHAs instead of version tags for GitHub Actions. - -## Security Requirement - -**ALWAYS use commit SHAs instead of version tags** for all GitHub Actions in workflows. - -### Format -```yaml -# Correct - use commit SHA with version comment -- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - -# Incorrect - never use version tags directly -- uses: actions/checkout@v4 -``` - -## Security Benefits - -1. **Immutable references**: Commit SHAs cannot be changed or moved -2. **Supply chain security**: Prevents tag hijacking and malicious updates -3. **Reproducible builds**: Exact same action code always used -4. **Audit trail**: Clear version tracking with proper documentation - -## Finding Commit SHAs - -For any GitHub Action version, find the commit SHA by: - -1. Navigate to the action's GitHub repository -2. Go to Releases page -3. Click on the specific version tag (e.g., v4, v1.2.3) -4. Copy the commit SHA from the release page -5. Use full SHA, not abbreviated version - -## Comment Requirements - -**ALWAYS include version comment** after the commit SHA: - -```yaml -- uses: owner/action@ # -``` - -Examples: -```yaml -- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 -- uses: actions/setup-node@b39b52d1213e96004bfcb1c61a8a6fa8ab84f3e8 # v4.0.1 -``` - -## Maintenance Process - -When updating GitHub Actions: - -1. **Check for new releases** on action's GitHub repository -2. **Find new commit SHA** from release page -3. **Update workflow file** with new SHA -4. **Update comment** with correct version number -5. **Test thoroughly** before merging - -## Current Sentry Lua Project SHAs - -Reference SHAs used in this project (update when versions change): - -- `actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4` -- `actions/create-github-app-token@0f859bf9e69e887678d5bbfbee594437cb440ffe # v2.1.0` -- `ilammy/msvc-dev-cmd@0b201ec74fa43914dc39ae48a89fd1d8cb592756 # v1` -- `leafo/gh-actions-lua@35bcb06abec04ec87df82e08caa84d545348536e # v10` -- `leafo/gh-actions-luarocks@e65774a6386cb4f24e293dca7fc4ff89165b64c5 # v4` -- `actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4` -- `codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4` -- `codecov/test-results-action@47f89e9acb64b76debcd5ea40642d25a4adced9f # v1` - -### Internal Sentry Actions (may use version tags) - -- `getsentry/action-prepare-release@v1` - Internal Sentry action, may not follow SHA pinning - -## Validation - -Before merging workflow changes: - -- [ ] All actions use full commit SHAs (not abbreviated) -- [ ] All SHAs have correct version comments -- [ ] No version tags (like @v4) remain in workflow -- [ ] Comments match actual release versions -- [ ] Workflow passes CI tests - -This prevents supply chain attacks and ensures reproducible, secure CI/CD pipelines. \ No newline at end of file diff --git a/.claude/memories/ldk-c-coverage.md b/.claude/memories/ldk-c-coverage.md deleted file mode 100644 index 4ed393d..0000000 --- a/.claude/memories/ldk-c-coverage.md +++ /dev/null @@ -1,112 +0,0 @@ -# C Module Coverage Procedures - -**This document describes coverage procedures specifically for C extension modules using lcov/gcov tools.** -For pure Lua module coverage procedures, see @.claude/memories/ldk-coverage.md - -## Build with Coverage Support -- Use project-specific environment variable to enable --coverage flag during build -- Example: `EXAMPLE_COVERAGE=1 luarocks make` -- This enables compiler coverage instrumentation for C extensions - -## Test Execution -- Run tests normally after coverage-enabled build: `testcase test/module_test.lua` -- DO NOT add `require('luacov')` to test files for C modules - -## Coverage Report Generation -Create a coverage generation script (e.g., `covgen.sh`) with content similar to: - -```bash -#!/bin/bash -# Generate coverage report for C modules - -set -e - -# Check if src directory exists -if [ ! -d "./src" ]; then - echo "Warning: src directory not found. Skipping coverage generation." - exit 0 -fi - -# Check if .gcno files exist in src directory -if ! find ./src -name "*.gcno" | grep -q .; then - echo "Warning: No .gcno files found in src directory. Skipping coverage generation." - echo "Note: To generate coverage data, compile and link with --coverage flag" - echo " Compile: gcc -c --coverage module.c" - echo " Link: gcc --coverage -o module.so module.o -shared" - exit 0 -fi - -echo "Generating coverage report..." - -# Clean previous coverage data -if [ -d "coverage" ]; then - echo "Removing previous coverage report..." - rm -rf coverage -fi - -# Get absolute path of src directory (resolves symlinks) -SRC_ABS_PATH=$(cd ./src && pwd) - -# Generate coverage data -lcov --capture --directory ./src --output-file coverage.info --ignore-errors gcov,source - -# Check if coverage data was generated successfully -if [ ! -f "coverage.info" ]; then - echo "Error: Failed to generate coverage.info" - exit 1 -fi - -# Extract only files from project's src directory -lcov --extract coverage.info "${SRC_ABS_PATH}/*" --output-file coverage.info --ignore-errors source - -# Generate HTML report -genhtml coverage.info --output-directory coverage --title "Project Coverage Report" - -# Check if HTML report was generated successfully -if [ ! -d "coverage" ] || [ ! -f "coverage/index.html" ]; then - echo "Error: Failed to generate HTML coverage report" - exit 1 -fi - -echo "Coverage report generated successfully!" -echo "HTML report: $(pwd)/coverage/index.html" -echo "" -echo "Coverage summary:" -lcov --summary coverage.info -``` - -## Usage -- Make script executable: `chmod +x covgen.sh` -- Generate report: `./covgen.sh` -- View HTML report in `coverage/index.html` - -## Platform-Specific Troubleshooting - -### macOS Issues -**Command not found**: Use `brew install lcov` if Homebrew installation failed -**Permission denied**: Check Xcode Command Line Tools installation with `xcode-select --install` -**Coverage data missing**: Verify GCC usage instead of Clang by setting `CC=gcc` in environment - -### Linux Issues -**Ubuntu/Debian**: Install `build-essential` package if GCC missing -**CentOS/RHEL**: Use `yum groupinstall "Development Tools"` for complete toolchain -**Generic Linux**: Ensure `--coverage` flag support with `gcc --help | grep coverage` - -### Build Integration Problems -**No .gcno files**: Verify @Makefile uses `--coverage` flag in both CFLAGS and LDFLAGS -**Partial coverage**: Check all C source files compile with coverage enabled -**Clean builds**: Remove `.gcda` files between runs to avoid stale data - -## Notes -- Requires lcov/gcov tools to be installed -- Coverage data is collected during test execution -- Clean build artifacts between coverage runs if needed -- **Coverage Target**: 95%+ (aim for 100% when practical) - -## Related Files -- @.claude/memories/ldk-coverage.md - Coverage procedures for pure Lua modules -- @.claude/memories/ldk-technology-stack.md - Installation instructions for lcov/gcov tools -- @.claude/memories/ldk-commands.md - Common development commands including build -- @.claude/memories/ldk-task-checklist.md - Complete development workflow including coverage -- @.claude/memories/ldk-test-guidelines.md - Guidelines for writing testable C extension code -- @.claude/memories/ldk-project-structure.md - Directory structure for C extensions and tests \ No newline at end of file diff --git a/.claude/memories/ldk-code-style.md b/.claude/memories/ldk-code-style.md deleted file mode 100644 index 692b5a6..0000000 --- a/.claude/memories/ldk-code-style.md +++ /dev/null @@ -1,169 +0,0 @@ -# Code Style Conventions - -**This document defines coding standards and best practices for Lua projects, including performance optimizations and function organization rules.** - -## Built-in Function Usage Conventions - -For performance and readability improvements, follow these conventions: - -### File-scope Local Variable Assignment - -Assign frequently used functions to file-scope local variables for performance and reliability: - -**What to localize:** -- Global functions: `local type = type`, `local pairs = pairs` -- Table methods: `local insert = table.insert`, `local floor = math.floor` -- Module functions: `local encode = require('json').encode` - -**Exceptions (keep global):** -- `assert` and `error` functions should remain global -- These may be overridden by application frameworks for error capture/handling -- Example: `assert(condition, "error message")` - use directly without localization - -**Why localize:** -1. **Performance**: Eliminates global table lookups -2. **Reliability**: Protects against runtime modifications -3. **Clarity**: Makes dependencies explicit - -### Avoid Object Method Syntax - -Use function form instead of method syntax to prevent issues with modified metatables: - -```lua --- Bad: Method syntax can fail if metatable is modified -local upper = str:upper() - --- Good: Function form is reliable -local upper = string.upper -local result = upper(str) -``` - -## Table Tail Append Optimization - -For performance improvement, use direct index operations for table tail appends: - -### Tail Append Optimization - -```lua --- Bad (function call overhead) -table.insert(tbl, value) -local insert = table.insert -insert(tbl, value) - --- Good (direct index operation, fastest) -tbl[#tbl + 1] = value -``` - -### Rationale - -1. **Performance**: Eliminates function call overhead -2. **Memory efficiency**: Direct operation minimizes memory access -3. **Clarity**: Intent of tail append is clear -4. **Consistency**: Uniform across entire project - -### Notes - -- Use `table.insert` for mid-table insertion or when nils are present -- Apply this convention only for tail append operations - -## Function Definition Order Rule - -**Critical Rule**: Functions must be defined in **dependency order from deepest to shallowest**. - -This follows the same constraint as C static functions without forward declarations: called functions must be defined before (above) their callers. - -### Implementation Strategy - -1. **Analyze the main function's call sequence** -2. **Place functions from top to bottom in order of dependency depth** -3. **Deepest dependencies first, main function last** - -### Example Pattern - -```lua --- Deepest dependency (used by intermediate functions) -local function helper_func() - return some_value -end - --- Intermediate dependency (used by main function) -local function process_data() - return helper_func() -- uses helper_func -end - --- Main function (uses all above functions) -local function main() - return process_data() -- uses process_data -end -``` - -### Real-world Application - -For a function that calls A, then B, then C (conditionally), arrange as: - -```lua --- Deepest/most distant calls first -local function C() ... end - --- Mid-level dependencies -local function B() ... end - --- Direct dependencies of main -local function A() ... end - --- Main function last -local function main() - A() -- first call - B() -- second call - if condition then - C() -- conditional call - end -end -``` - -**Key Principle**: Read the main function's call sequence, then arrange dependencies in reverse dependency order (deepest first, main last). - -### Implementation Decision Tree - -**For simple linear calls**: Place called functions immediately above caller -**For conditional calls**: Place deepest conditional functions first -**For shared utilities**: Place at top of file before any callers -**For recursive functions**: Define before any external callers - -### Performance Impact - -**Local variable caching** provides 15-25% performance improvement in tight loops. **Direct table indexing** (`tbl[#tbl + 1] = value`) outperforms `table.insert()` by 10-20% for tail appends. **Function definition order** enables better Lua compiler optimization. - -### Application Scope - -This convention applies to: -- All Lua files under lua/ -- All C files under src/ (similar optimization with corresponding C functions) -- Test files under test/ - -### Benefits - -1. **Performance**: Reduces table lookup overhead -2. **Readability**: Functions in use are immediately visible -3. **Debug efficiency**: Function references are clear -4. **Consistency**: Unified style across entire project -5. **Natural flow**: Matches C static function constraints - -## Common Optimization Patterns - -### Memory Efficiency -- **Pre-allocate tables** with known size using `table.new(narray, nhash)` when available -- **Reuse tables** instead of creating new ones in loops -- **String concatenation**: Use `table.concat()` for multiple strings, direct concatenation for 2-3 strings - -### Error Handling Optimization -- **Fast path first**: Place common success cases before error checks -- **Early returns**: Use guard clauses to reduce nesting depth -- **Error context**: Include sufficient context in error messages for debugging without verbose traces - -## Related Files -- @.claude/memories/ldk-commands.md - Commands for linting and formatting (luacheck, lua-format) -- @.claude/memories/ldk-test-guidelines.md - Code style in test files and test organization -- @.claude/memories/ldk-task-checklist.md - Style checking as part of development workflow -- @.claude/memories/ldk-technology-stack.md - Installation and setup for code quality tools -- @.claude/memories/ldk-project-structure.md - File organization and naming conventions diff --git a/.claude/memories/ldk-commands.md b/.claude/memories/ldk-commands.md deleted file mode 100644 index 6f5e685..0000000 --- a/.claude/memories/ldk-commands.md +++ /dev/null @@ -1,55 +0,0 @@ -# Lua Development Commands - -**This document lists common commands used during Lua project development, including build, test, and code quality tools.** -For detailed testing guidelines, see @.claude/memories/ldk-test-guidelines.md -For coverage procedures, see @.claude/memories/ldk-coverage.md (Lua) or @.claude/memories/ldk-c-coverage.md (C extensions). - -## Installation and Build -- `luarocks make` - Install the project (run this before testing) -- `make` - Build C extensions -- `make clean` - Clean build artifacts - -## Testing - -### testcase framework -- `testcase test/` - Run all tests (executes all `**/*_test.lua` files in directory) -- `testcase test/_test.lua` - Run specific test file -- `testcase test//` - Run tests in specific subdirectory - -### busted framework -- `busted` - Run all specs in spec/ directory -- `busted spec/_spec.lua` - Run specific spec file -- `busted spec//` - Run specs in specific subdirectory - -## Code Quality -- `luacheck .` - Run linting on Lua files -- `lua-format -i ` - Format a Lua file - -## Development Best Practices -- Always run `luarocks make` before testing -- Choose either `testcase` (test/) or `busted` (spec/) framework for consistency -- Coverage target: 95%+ (aim for 100% when practical) -- Code comments should be in English -- Follow project's formatting rules (4 spaces, single quotes) - -## Quick Reference -```bash -# Complete development cycle -luarocks make # Install/build project -testcase test/ # OR busted # Run all tests -luacheck . # Check code style -lua-format -i src/myfile.lua # Format specific file - -# Coverage workflows -require('luacov') # Add to Lua test files -EXAMPLE_COVERAGE=1 luarocks make # C extension coverage build -``` - -## Related Files -- @.claude/memories/ldk-technology-stack.md - Installation instructions for all development tools -- @.claude/memories/ldk-code-style.md - Detailed coding standards and style rules -- @.claude/memories/ldk-test-guidelines.md - Guidelines for writing and organizing tests -- @.claude/memories/ldk-task-checklist.md - Complete development workflow checklist -- @.claude/memories/ldk-coverage.md - Lua module coverage procedures -- @.claude/memories/ldk-c-coverage.md - C extension coverage procedures -- @.claude/memories/ldk-project-structure.md - Project organization and file structure \ No newline at end of file diff --git a/.claude/memories/ldk-coverage.md b/.claude/memories/ldk-coverage.md deleted file mode 100644 index 89d193d..0000000 --- a/.claude/memories/ldk-coverage.md +++ /dev/null @@ -1,112 +0,0 @@ -# Lua Module Coverage Procedures - -**This document describes coverage procedures for pure Lua modules using luacov.** -For C extension module coverage procedures, see @.claude/memories/ldk-c-coverage.md - -## Setup and Configuration -- Add `require('luacov')` at the top of test files -- Configure @.luacov file with module names and paths -- Set `deletestats = true` (can be changed to false to keep stats) - -### Example @.luacov Configuration -Create a @.luacov file in your project root: -```lua --- @.luacov configuration file -return { - -- Enable statistics collection - statsfile = "luacov.stats.out", - - -- Coverage report output file - reportfile = "luacov.report.out", - - -- Delete previous stats before new run (recommended for clean results) - deletestats = true, - - -- Modules to include in coverage (adjust paths to match your project) - include = { - "^lua/", -- Include all modules in lua/ directory - "^src/", -- Include modules in src/ directory - "mymodule", -- Include specific module - }, - - -- Modules to exclude from coverage - exclude = { - "test/", -- Exclude testcase files - "spec/", -- Exclude busted spec files - "bench/", -- Exclude benchmark files - "luacov$", -- Exclude luacov itself - "luarocks", -- Exclude luarocks modules - }, - - -- Coverage target (lines must be hit to count as covered) - -- Target: 95%+ coverage (aim for 100% when practical) -} -``` - -## Execution -- Run `luarocks make` to install/build project -- Execute tests: `testcase test/module_test.lua` -- Coverage report is automatically generated in `luacov.report.out` - -## Verification -- Review coverage report for completeness -- Identify untested code paths -- Add tests for uncovered areas to achieve target coverage (95%+) - -### Reading Coverage Reports -The `luacov.report.out` file shows: -- **Total Coverage**: Overall percentage across all included files -- **File Coverage**: Per-file line coverage statistics -- **Line Details**: Specific lines that were/weren't executed -- **Hit Count**: Number of times each line was executed - -Example report excerpt: - -``` -============================================================================== -mymodule.lua -============================================================================== - 1 : local function helper() - 2 : return "test" -- Hit 5 times - 3 : end - 4 : -*0 : local function unused() -- Never executed (0 hits) -*0 : return "unused" -*0 : end -``` - -Lines marked with `*0` indicate uncovered code that needs testing. - -## Configuration Patterns by Project Type - -### Simple Module Projects -**Single module**: Include pattern `"^mymodule$"`, exclude test directories (`"test/"` and `"spec/"`) -**Library projects**: Include `"^lua/"`, exclude test directories and external dependencies -**Mixed C/Lua**: Include Lua paths only, exclude C extensions (use separate C coverage) -**Framework choice**: Exclude `"test/"` for testcase or `"spec/"` for busted (or both for mixed projects) - -### Advanced Configuration Options -- **`includeuntestedfiles = true`**: Shows files with zero coverage (useful for new codebases) -- **`savestepsize = 100`**: Saves stats every 100 lines (useful for long-running tests) -- **`codefromstring = true`**: Includes coverage for dynamically loaded code - -## Troubleshooting Coverage Issues - -### Common Problems -**No coverage data**: Check `require('luacov')` appears before module loading -**Missing files**: Verify include/exclude patterns match your project structure -**Zero coverage**: Ensure test files are executing the target code paths -**Incomplete coverage**: Review exclude patterns - may be too broad - -### Performance Considerations -**Large projects**: Use specific include patterns to avoid tracking unnecessary files -**CI environments**: Set `deletestats = true` to ensure clean runs -**Development**: Set `deletestats = false` to accumulate coverage across test sessions - -## Related Files -- @.claude/memories/ldk-c-coverage.md - Coverage procedures for C extension modules -- @.claude/memories/ldk-technology-stack.md - Installation instructions for luacov and setup -- @.claude/memories/ldk-commands.md - Commands for running tests and coverage -- @.claude/memories/ldk-task-checklist.md - Complete development workflow including coverage -- @.claude/memories/ldk-test-guidelines.md - Writing tests that generate good coverage -- @.claude/memories/ldk-project-structure.md - Directory structure for modules and tests diff --git a/.claude/memories/ldk-index.md b/.claude/memories/ldk-index.md deleted file mode 100644 index 0f6856c..0000000 --- a/.claude/memories/ldk-index.md +++ /dev/null @@ -1,36 +0,0 @@ -# Lua Development Kit Memory Index - -Central reference point for all LDK memory files. - -## Core Development Memories - -@.claude/memories/lua-c-api.md - Lua C API development: luaopen_* functions, stack operations, userdata creation, and proper error handling with lua_error() -@.claude/memories/lua-module-patterns.md - Lua module development patterns: return table exports, require() usage, local vs global scope, and module initialization -@.claude/memories/luarocks-integration.md - LuaRocks build system: rockspec file structure, build dependencies, installation paths, and version management -@.claude/memories/makefile-patterns.md - Makefile development for Lua C extensions: compiler flags, library linking, installation rules, and cross-platform compatibility - -## Build System Memories - -@.claude/memories/build-system-design.md - Advanced build system architecture: mixed Lua/C/C++ projects, module grouping, and dynamic target generation -@.claude/memories/cross-platform-building.md - Cross-platform build considerations: compiler detection, platform-specific flags, library extensions (.so/.dll), and installation paths -@.claude/memories/ldk-source-directives.md - Source file directive system: @cflags:, @ldflags:, @reflibs:, @cppflags:, @cxxflags: for automatic build configuration in C/C++ source files -@.claude/memories/ldk-static-libraries.md - Static library build system: lib/ directory structure, .a file generation, prefix grouping, mixed C/C++ libraries, and @reflibs: linking - -## Project Structure Memories - -@.claude/memories/project-layout.md - Standard Lua project structure: src/ for C code, lua/ for Lua modules, spec/ for tests, and proper file organization -@.claude/memories/file-organization.md - File organization best practices: module naming conventions, directory hierarchies, and separation of concerns - -## Testing and Quality - -@.claude/memories/testing-strategies.md - Testing strategies for Lua projects: busted framework, spec files, C extension testing, and continuous integration setup - -## Documentation and Contribution - -@.claude/memories/contributing-sync.md - CONTRIBUTING.md and CI workflow synchronization: system dependencies, build requirements, platform matrix, and sync verification checklist -@.claude/memories/github-actions-security.md - GitHub Actions security: always use commit SHAs instead of version tags, with proper version comments for maintenance - -## Usage - -This index provides targeted access to LDK memory files based on development context. -Reference in CLAUDE.md with: `@.claude/memories/ldk-index.md` diff --git a/.claude/memories/ldk-memory-conventions.md b/.claude/memories/ldk-memory-conventions.md deleted file mode 100644 index 5bde03b..0000000 --- a/.claude/memories/ldk-memory-conventions.md +++ /dev/null @@ -1,25 +0,0 @@ -# Memory File Conventions - -**This document defines conventions for creating and maintaining memory files used with Serena MCP or similar memory management systems.** - -## Language Convention -- All memory files must be written in English -- This ensures consistency and accessibility across the project -- Technical documentation, procedures, and architectural notes should use English - -## File Organization Principles -- **Separate concerns**: Each memory file should focus on a single topic or domain -- **Avoid mixing**: Don't combine unrelated concepts in the same file -- **Clear naming**: File names should clearly indicate their purpose and scope - -## Content Guidelines -- Use clear, concise language that is easy to understand -- Structure content logically with appropriate headings -- Include practical examples where helpful -- Keep information current and accurate - -## Maintenance -- Regularly review memory files for relevance and accuracy -- Remove or update obsolete information -- Split files that become too large or cover multiple topics -- Merge files that are too small or closely related diff --git a/.claude/memories/ldk-project-structure.md b/.claude/memories/ldk-project-structure.md deleted file mode 100644 index 38ba29d..0000000 --- a/.claude/memories/ldk-project-structure.md +++ /dev/null @@ -1,49 +0,0 @@ -# Lua Project Structure Guidelines - -**This document defines the standard directory structure and file organization for Lua projects.** -For test code guidelines, see @.claude/memories/ldk-test-guidelines.md - -## Directory Organization - -**Path Structure Principle**: All directories mirror the `require()` module path structure. For `require('foo.bar.baz')`, files are organized as `/foo/bar/baz.`. - -- **Lua modules**: `lua/**/*.lua` -- **C extensions**: `src/**/*.c` -- **Tests**: `test/**/*_test.lua` (testcase) OR `spec/**/*_spec.lua` (busted) (one-to-one correspondence with modules) -- **Benchmarks**: `bench/**/*_bench.lua` or `test/bench/**/*_bench.lua` (one-to-one correspondence) -- **Documentation**: `doc/` or `docs/` -- **Configuration**: Root level (@.luacheckrc, @.luacov, etc.) - -## Module to Test Mapping -- **One-to-one correspondence**: Each module has exactly one test file -- **Path mirroring**: Test file paths mirror the module require path - -### testcase framework -- `require('foo.bar.baz')` → `test/foo/bar/baz_test.lua` -- `require('utils.string')` → `test/utils/string_test.lua` -- `require('parser')` → `test/parser_test.lua` - -### busted framework -- `require('foo.bar.baz')` → `spec/foo/bar/baz_spec.lua` -- `require('utils.string')` → `spec/utils/string_spec.lua` -- `require('parser')` → `spec/parser_spec.lua` - -## File Naming Conventions -- **Test files**: `_test.lua` (testcase) OR `_spec.lua` (busted) -- **Benchmark files**: `_bench.lua` -- **Module files**: Use clear, descriptive names matching functionality - -## Project Root Files -- @rockspec - LuaRocks specification file -- @Makefile - Build configuration (for C extensions) -- @.luacheckrc - Linting configuration -- @.luacov - Coverage configuration -- @README.md - Project documentation - -## Related Files -- @.claude/memories/ldk-test-guidelines.md - Detailed guidelines for test organization and structure -- @.claude/memories/ldk-commands.md - Commands that work with this directory structure -- @.claude/memories/ldk-code-style.md - File naming and organization conventions -- @.claude/memories/ldk-coverage.md - Coverage procedures that follow this structure -- @.claude/memories/ldk-c-coverage.md - C extension structure and coverage -- @.claude/memories/ldk-memory-conventions.md - General file organization principles diff --git a/.claude/memories/ldk-source-directives.md b/.claude/memories/ldk-source-directives.md deleted file mode 100644 index 35efc62..0000000 --- a/.claude/memories/ldk-source-directives.md +++ /dev/null @@ -1,186 +0,0 @@ -# Source File Directives for Lua C Extensions - -Comprehensive guide to using comment directives in C/C++ source files for automatic build configuration. - -## Overview - -Source file directives allow you to specify compiler and linker flags directly in your C/C++ source files using specially formatted comments. The makemk.lua script automatically parses these directives and applies them during compilation and linking. - -## Directive Syntax - -All directives use the format: `//@directive: value` - -- Must be in comment blocks (single-line `//` or multi-line `/* */`) -- Case-insensitive directive names -- Must appear before any actual code (before first non-comment, non-preprocessor line) -- Each directive can only appear once per file - -## Available Directives - -### @cppflags: - Preprocessor Flags -Used for C/C++ preprocessor directives, include paths, and macro definitions. - -```c -//@cppflags: -DDEBUG -DVERSION=1.0 -Iinclude -I/usr/local/include - -#include -// rest of code... -``` - -**Common usage:** -- `-DMACRO_NAME` - Define preprocessor macros -- `-DMACRO=value` - Define macros with values -- `-Ipath` - Add include directories -- `-I/absolute/path` - Absolute include paths - -### @cflags: - C Compiler Flags -Specific flags for C compilation (*.c files). - -```c -//@cflags: -Wall -Werror -std=c11 -pedantic - -#include -int main() { return 0; } -``` - -**Common usage:** -- `-Wall` - Enable all warnings -- `-Werror` - Treat warnings as errors -- `-std=c11` - Set C standard version -- `-pedantic` - Strict standard compliance -- `-O2`, `-O3` - Optimization levels - -### @cxxflags: - C++ Compiler Flags -Specific flags for C++ compilation (*.cpp files). - -```cpp -//@cxxflags: -std=c++17 -Wall -Wextra - -#include -int main() { return 0; } -``` - -**Common usage:** -- `-std=c++11/14/17/20` - C++ standard version -- `-Wall -Wextra` - Extended warnings -- `-fno-exceptions` - Disable exceptions -- `-fno-rtti` - Disable RTTI - -### @ldflags: - Linker Flags -Flags passed to the linker for library linking and linker options. - -```c -//@ldflags: -lm -lpthread -L/usr/local/lib -lcurl - -#include -#include -// code using math and pthread functions... -``` - -**Common usage:** -- `-lname` - Link against library (e.g., `-lm` for math library) -- `-Lpath` - Add library search path -- `-Wl,option` - Pass options directly to linker -- `-framework name` - Link macOS frameworks - -### @reflibs: - Static Library References -References to static libraries built in the lib/ directory. - -```c -//@reflibs: string util/memory - -#include "string_utils.h" -#include "util/memory.h" -// code that uses functions from lib/string.a and lib/util/memory.a -``` - -**Usage:** -- Space-separated list of library names -- `string` → links against `lib/string.a` -- `util/memory` → links against `lib/util/memory.a` -- Automatically handles dependency ordering - -## Multi-Line Comments - -Directives work in both single-line and multi-line comments: - -```c -/* - * @cppflags: -DDEBUG -Iinclude - * @cflags: -Wall -Werror - * @ldflags: -lm -lpthread - * @reflibs: string util/memory - */ - -#include -// rest of code... -``` - -## Complex Example - -Real-world example combining multiple directives: - -```c -/* - * Network module with PostgreSQL and cURL dependencies - * @cppflags: -DUSE_POSTGRESQL -DUSE_CURL -DUSE_ZLIB - * @cflags: -Wall -Werror -std=c11 - * @ldflags: -lpq -lcurl -lz - * @reflibs: string util/memory - */ - -#include -#include -#include -#include "string_utils.h" -#include "util/memory.h" - -// Network module implementation... -``` - -This generates a module that: -- Defines macros for PostgreSQL, cURL, and zlib support -- Uses strict C11 compilation -- Links against PostgreSQL, cURL, and zlib system libraries -- Links against project's string and util/memory static libraries - -## Error Handling - -### Duplicate Directive Error -```c -//@cflags: -Wall -//@cflags: -Werror // ERROR: Duplicate directive -``` - -### Invalid Placement -```c -#include // Code started -//@cflags: -Wall // ERROR: Too late, must come before code -``` - -## Integration with Build System - -1. **makemk.lua** scans all .c/.cpp files in src/ directory -2. Parses directives from file headers -3. Groups files by prefix into modules -4. Generates appropriate build targets with merged flags -5. **Makefile** executes the generated build rules - -## Best Practices - -1. **Place directives at file top** - Before any includes or code -2. **Use consistent formatting** - One directive per line for readability -3. **Document complex flags** - Add comments explaining unusual flags -4. **Group related directives** - Keep related flags together -5. **Test incrementally** - Add one directive at a time when debugging - -## Troubleshooting - -### Common Issues -- **Directive not found**: Check spelling and placement before code -- **Compilation errors**: Verify flag syntax and library availability -- **Linking failures**: Ensure referenced libraries exist and are in library paths -- **@reflibs not working**: Verify static library exists in lib/ directory - -### Debug Output -Run `lua makemk.lua` directly to see directive parsing output and any errors. \ No newline at end of file diff --git a/.claude/memories/ldk-static-libraries.md b/.claude/memories/ldk-static-libraries.md deleted file mode 100644 index 64d18ca..0000000 --- a/.claude/memories/ldk-static-libraries.md +++ /dev/null @@ -1,258 +0,0 @@ -# Static Library Build System - -Comprehensive guide to building and using static libraries (.a files) in Lua C extension projects. - -## Overview - -The static library system allows you to build reusable C/C++ code into static libraries (.a files) that can be linked into your dynamic Lua modules. This enables code sharing, better organization, and modular development for complex projects. - -## Directory Structure - -``` -project/ -├── lib/ # Static libraries (NEW in v0.2.0) -│ ├── string.c # → lib/string.a -│ ├── string_helper.c # → lib/string.a (grouped by prefix) -│ ├── util/ -│ │ └── memory.c # → lib/util/memory.a -│ └── parser/ -│ ├── json.c # → lib/parser/json.a -│ └── json_validator.cpp # → lib/parser/json.a (mixed C/C++) -├── lua/ # Lua modules (MOVED from lib/) -│ ├── mypackage.lua # Main module -│ └── mypackage/ -│ └── helper.lua # Sub-module -└── src/ # Dynamic Lua modules (.so files) - ├── core.c # → core.so (can link to lib/*.a) - └── network.c # → network.so -``` - -## Build Process - -### 1. Static Library Generation -```bash -# makemk.lua scans lib/ directory -# Groups files by prefix: string.c + string_helper.c → string module -# Generates build targets: lib/string.a, lib/util/memory.a, lib/parser/json.a - -lua makemk.lua # Creates mk/modules.mk with static library targets -``` - -### 2. Static Library Compilation -```bash -# C files compiled to object files -gcc -c lib/string.c -o lib/string.o -gcc -c lib/string_helper.c -o lib/string_helper.o - -# C++ files compiled with appropriate compiler -g++ -c lib/parser/json_validator.cpp -o lib/parser/json_validator.o - -# Object files archived into static libraries -ar rcs lib/string.a lib/string.o lib/string_helper.o -ar rcs lib/parser/json.a lib/parser/json.o lib/parser/json_validator.o -``` - -### 3. Dynamic Module Linking -```bash -# Dynamic modules can reference static libraries via @reflibs: directive -# Example in src/network.c: -# //@reflibs: string util/memory - -# Linking resolves static library dependencies automatically -gcc -o src/network.so src/network.o lib/string.a lib/util/memory.a -bundle -``` - -## Prefix Grouping Rules - -Static libraries follow the same prefix grouping rules as dynamic modules: - -### Simple Grouping -``` -lib/ -├── string.c # Base name: "string" -├── string_helper.c # Prefix matches: "string_" → grouped with string.c -└── string_utils.c # Prefix matches: "string_" → grouped with string.c -``` -**Result**: Single `lib/string.a` containing all three object files. - -### Separate Libraries -``` -lib/ -├── parser.c # Base name: "parser" -├── lexer.c # Base name: "lexer" (no prefix match) -└── tokenizer.c # Base name: "tokenizer" (no prefix match) -``` -**Result**: Three separate libraries: `lib/parser.a`, `lib/lexer.a`, `lib/tokenizer.a`. - -### Nested Directories -``` -lib/ -├── util/ -│ ├── memory.c # → lib/util/memory.a -│ └── memory_pool.c # Grouped with memory.c → lib/util/memory.a -└── parser/ - ├── json.c # → lib/parser/json.a - └── xml.c # → lib/parser/xml.a (separate, no prefix match) -``` - -## Mixed C/C++ Static Libraries - -Static libraries can contain both C and C++ object files: - -``` -lib/parser/ -├── json.c # Compiled with gcc -└── json_validator.cpp # Compiled with g++, adds -lstdc++ to linker flags -``` - -**Generated makefile target**: -```makefile -lib_parser_json_LINKER = $(CXX) # Uses C++ linker -lib_parser_json_LDFLAGS = -lstdc++ # Adds C++ standard library -``` - -Any dynamic module linking to this static library will automatically use the C++ linker. - -## Using Static Libraries in Dynamic Modules - -### 1. Reference in Source File -Use the `@reflibs:` directive to specify static library dependencies: - -```c -// src/network.c -//@reflibs: string util/memory parser/json - -#include "string_utils.h" -#include "util/memory.h" -#include "parser/json.h" - -// Network module implementation using static library functions -``` - -### 2. Automatic Linking -The build system automatically: -- Links specified static libraries: `lib/string.a lib/util/memory.a lib/parser/json.a` -- Inherits static library linker flags (e.g., `-lstdc++` for C++ libraries) -- Ensures proper dependency ordering -- Uses appropriate linker (gcc vs g++) - -### 3. Generated Build Target -```makefile -src/network.so: src/network.o lib/string.a lib/util/memory.a lib/parser/json.a - $(CXX) -o $@ $^ $(LDFLAGS) $(PLATFORM_LDFLAGS) -lstdc++ -``` - -## Advanced Usage Patterns - -### Layered Dependencies -``` -lib/ -├── foundation/ -│ └── memory.c # Base utilities -├── collections/ -│ └── string.c # Uses foundation (via @reflibs: foundation/memory) -└── protocols/ - └── http.c # Uses both (via @reflibs: foundation/memory collections/string) -``` - -### Platform-Specific Libraries -```c -// lib/platform/posix.c -//@cppflags: -D_POSIX_C_SOURCE=200809L -//@ldflags: -lpthread - -// Platform-specific implementation -``` - -### Third-Party Integration -```c -// lib/crypto/hash.c -//@cppflags: -I/usr/local/include -//@ldflags: -L/usr/local/lib -lcrypto -lssl - -// OpenSSL wrapper functions -``` - -## Build Integration - -### Generated Makefile Targets -makemk.lua generates targets for each static library: - -```makefile -# lib/string static library -lib_string_SRC := lib/string.c lib/string_helper.c -lib_string_OBJS := $(lib_string_SRC:.c=.o) -lib_string_LINKER = $(CC) -lib_string_LDFLAGS = - -lib/string.a: $(lib_string_OBJS) - @mkdir -p $(@D) - $(AR) rcs $@ $^ -``` - -### Module Dependencies -Dynamic modules with `@reflibs:` dependencies get: - -```makefile -src_network_LDFLAGS = lib/string.a lib/util/memory.a -src/network.so: src/network.o lib/string.a lib/util/memory.a - $(CC) -o $@ $^ $(LDFLAGS) $(src_network_LDFLAGS) -``` - -## Best Practices - -### Organization -1. **Group related functionality** - Use prefix grouping for related functions -2. **Separate concerns** - Different subdirectories for different domains -3. **Minimize dependencies** - Keep static libraries focused and minimal - -### Header Files -1. **Create corresponding headers** - `lib/string.c` → `include/string.h` -2. **Use include guards** - Standard `#ifndef`/`#define`/`#endif` pattern -3. **Document interfaces** - Clear function documentation in headers - -### Testing -1. **Unit test static libraries** - Test libraries independently -2. **Integration testing** - Test dynamic modules using static libraries -3. **Coverage analysis** - Use coverage flags for static library code - -## Troubleshooting - -### Common Issues -- **Undefined symbols**: Static library not properly linked or missing functions -- **Multiple definitions**: Same symbol defined in multiple static libraries -- **Linker errors**: Wrong linker (gcc vs g++) for mixed C/C++ libraries -- **Missing libraries**: @reflibs references non-existent static library - -### Debug Commands -```bash -# List contents of static library -ar -t lib/string.a - -# Show symbol table -nm lib/string.a - -# Verbose build to see linking -make V=1 - -# Check generated makefile -cat mk/modules.mk -``` - -### Clean Rebuilds -```bash -# Clean all build artifacts including static libraries -make clean - -# Force regeneration of mk/modules.mk -rm -f mk/modules.mk -lua makemk.lua -``` - -## Migration from v0.1.1 - -In v0.1.1, the `lib/` directory was used for Lua modules. In v0.2.0+: -- **Lua modules moved**: `lib/` → `lua/` -- **Static libraries added**: New `lib/` directory for C/C++ static libraries -- **Update require paths**: Lua code may need `require('package.module')` updates -- **Update build scripts**: Any custom scripts referencing old `lib/` directory \ No newline at end of file diff --git a/.claude/memories/ldk-task-checklist.md b/.claude/memories/ldk-task-checklist.md deleted file mode 100644 index 4f1bc9f..0000000 --- a/.claude/memories/ldk-task-checklist.md +++ /dev/null @@ -1,57 +0,0 @@ -# Development Task Checklist - -**This document provides a comprehensive checklist for development tasks, ensuring quality and consistency across the project.** -For specific commands, see @.claude/memories/ldk-commands.md -For code style details, see @.claude/memories/ldk-code-style.md - -When completing a coding task, always: - -## 1. Before Starting -- Run build/installation commands to ensure environment is set up -- Read relevant documentation in project documentation directory -- Check existing code patterns in similar files - -## 2. During Development -- Follow project's code style conventions (indentation, naming) -- Write clear comments for complex logic -- Use established design patterns for new components -- Implement proper error handling patterns - -## 3. After Implementation -- Run linting tools to check for code quality issues -- Format code according to project standards -- Write comprehensive tests with appropriate coverage -- Run test suite to ensure all tests pass -- Achieve coverage target: 95%+ (aim for 100% when practical) - -## 4. Final Checks -- If native code was modified, ensure proper resource management -- Update relevant documentation if API changed -- Run coverage reports if project uses them -- Ensure no debug prints or temporary code remains -- Verify no restricted commands are used (if applicable) - -## Quality Standards -- Always verify tests pass before considering task complete -- Follow existing patterns and conventions in the codebase -- Maintain consistency with project architecture and design - -## Quick Reference Checklist -- [ ] `luarocks make` (setup environment) -- [ ] Read docs and check existing patterns -- [ ] Follow code style conventions -- [ ] Write comprehensive tests -- [ ] `testcase test/` OR `busted` (run tests) -- [ ] `luacheck .` (check code quality) -- [ ] Achieve 95%+ coverage target -- [ ] Update docs if API changed -- [ ] Clean up debug code - -## Related Files -- @.claude/memories/ldk-commands.md - Specific commands for each checklist step -- @.claude/memories/ldk-code-style.md - Style rules and formatting standards -- @.claude/memories/ldk-test-guidelines.md - Guidelines for comprehensive test writing -- @.claude/memories/ldk-coverage.md - Lua coverage procedures for checklist step -- @.claude/memories/ldk-c-coverage.md - C extension coverage procedures -- @.claude/memories/ldk-technology-stack.md - Tool setup and installation requirements -- @.claude/memories/ldk-project-structure.md - Project organization and file conventions \ No newline at end of file diff --git a/.claude/memories/ldk-technology-stack.md b/.claude/memories/ldk-technology-stack.md deleted file mode 100644 index f6a36b9..0000000 --- a/.claude/memories/ldk-technology-stack.md +++ /dev/null @@ -1,143 +0,0 @@ -# Technology Stack - -**This document outlines the core technologies and tools used in Lua projects.** -For development commands using these tools, see @.claude/memories/ldk-commands.md - -## Core Language and Runtime - -- **Language**: Lua (5.1.5+) with C extensions for performance-critical parts - - **Required Version**: Lua 5.1.5 (mandatory for LuaJIT compatibility) - - **Recommended**: Lua 5.3 or 5.4 for improved performance when LuaJIT not required - - **Platform Support**: Cross-platform (Linux, macOS, Windows) - - **LuaJIT Support**: All code must be compatible with LuaJIT's Lua 5.1.5 implementation - -## Build System and Package Management - -- **Package Manager**: luarocks - - **Prerequisite**: luarocks must be installed and functional - - **Version**: 3.0+ recommended for better dependency resolution - - **Usage**: Manages Lua packages and dependencies - -- **Build System**: @Makefile with luarocks integration - - **Purpose**: Compiles C extensions and manages build artifacts - - **Requirements**: GCC or compatible C compiler for C extensions - - **Integration**: `luarocks make` automatically invokes @Makefile for C components - -## Testing Framework - -Choose one testing framework for your project: - -### Option 1: lua-testcase -- **Installation**: `luarocks install testcase` -- **Directory**: `test/` with `*_test.lua` files -- **Usage**: `testcase test/` runs all tests recursively -- **Assertions**: Uses lua-assert for expressive test assertions -- **Reference**: For detailed usage, refer to https://github.com/mah0x211/lua-testcase - -### Option 2: busted -- **Installation**: `luarocks install busted` -- **Directory**: `spec/` with `*_spec.lua` files -- **Usage**: `busted` runs all specs in spec/ directory -- **Features**: BDD-style testing with describe/it syntax -- **Reference**: For detailed usage, refer to https://github.com/lunarmodules/busted - -## Code Quality Tools - -- **Linting**: luacheck - - **Installation**: `luarocks install luacheck` - - **Purpose**: Static analysis and style checking for Lua code - - **Configuration**: @.luacheckrc file in project root - - **Usage**: `luacheck .` for project-wide linting - -- **Formatting**: lua-format - - **Installation**: `luarocks install lua-format` or platform-specific package managers - - **Purpose**: Automatic code formatting for consistency - - **Standards**: 4 spaces indentation, single quotes preferred - - **Usage**: `lua-format -i ` for in-place formatting - -## Coverage Tools - -- **Lua Module Coverage**: luacov - - **Installation**: `luarocks install luacov` - - **Purpose**: Line coverage analysis for pure Lua code - - **Configuration**: @.luacov file for coverage settings - - **Output**: `luacov.report.out` with detailed coverage report - -- **C Extension Coverage**: lcov/gcov - - **Installation**: - - **Ubuntu/Debian**: `apt-get install lcov` - - **CentOS/RHEL**: `yum install lcov` or `dnf install lcov` - - **macOS**: `brew install lcov` - - **Purpose**: Line coverage analysis for C extension code - - **Requirements**: GCC with `--coverage` flag support - - **Output**: HTML coverage reports via `genhtml` - -## Development Dependencies - -- **C Compiler**: GCC 4.8+ or Clang 3.5+ - - **Purpose**: Required for building C extensions - - **Features**: Must support `--coverage` flag for coverage analysis - -- **Make**: GNU Make 3.8+ - - **Purpose**: Build automation for C extensions - - **Platform**: Available on most Unix-like systems - -## Development Tools Installation - -**Prerequisites**: Lua 5.1.5+ and luarocks must be installed via your preferred method (version managers, package managers, or source compilation). - -### Tool Installation via luarocks -```bash -# Choose one testing framework -luarocks install testcase # OR luarocks install busted - -# Essential development tools -luarocks install luacheck -luarocks install luacov -luarocks install lua-format - -# Platform-specific coverage tools -# Ubuntu/Debian: apt-get install lcov -# macOS: brew install lcov -# CentOS/RHEL: yum install lcov -``` - -## Version Compatibility - -- **Lua 5.1.5**: Required minimum - ensures LuaJIT compatibility -- **Lua 5.2**: Compatible but avoid 5.2-specific features for LuaJIT support -- **Lua 5.3**: Recommended for pure Lua projects - integer support, better performance -- **Lua 5.4**: Latest features but may not be LuaJIT compatible -- **LuaJIT**: Primary target runtime - significant performance benefits, industry standard - -## Integration Notes - -- All tools integrate through luarocks package manager -- C extensions require proper compiler toolchain setup -- Coverage tools require specific build flags for accurate reporting -- Testing framework expects specific file naming conventions (`*_test.lua` for testcase, `*_spec.lua` for busted) -- **LuaJIT Compatibility**: All code must run on both standard Lua 5.1.5 and LuaJIT without modification - -## Quick Reference -```bash -# Prerequisites: Lua 5.1.5+ and luarocks must be installed - -# Choose testing framework and install tools -luarocks install testcase # OR luarocks install busted -luarocks install luacheck -luarocks install luacov -luarocks install lua-format - -# Coverage tools (platform-specific) -# macOS: brew install lcov -# Ubuntu/Debian: apt-get install lcov -# CentOS/RHEL: yum install lcov -``` - -## Related Files -- @.claude/memories/ldk-commands.md - How to use these tools in daily development -- @.claude/memories/ldk-coverage.md - Using luacov for Lua module coverage -- @.claude/memories/ldk-c-coverage.md - Using lcov/gcov for C extension coverage -- @.claude/memories/ldk-task-checklist.md - Tool usage as part of development workflow -- @.claude/memories/ldk-code-style.md - Standards enforced by luacheck and lua-format -- @.claude/memories/ldk-test-guidelines.md - Using testcase framework effectively diff --git a/.claude/memories/ldk-test-guidelines.md b/.claude/memories/ldk-test-guidelines.md deleted file mode 100644 index 997495f..0000000 --- a/.claude/memories/ldk-test-guidelines.md +++ /dev/null @@ -1,177 +0,0 @@ -# Test Code Guidelines - -**This document provides comprehensive guidelines for writing and organizing test code in Lua projects.** -For project structure details, see @.claude/memories/ldk-project-structure.md -For coverage procedures, see @.claude/memories/ldk-coverage.md (Lua) or @.claude/memories/ldk-c-coverage.md (C extensions). - -## General Principles -- Write clean, maintainable test code that is easy to understand and modify -- Each test function should focus on a single aspect of functionality -- Use descriptive function names that clearly indicate what is being tested -- Keep tests concise while maintaining thorough coverage -- Follow the AAA pattern: Arrange, Act, Assert - -## Helper Functions and Shared Utilities -- Create reusable helper functions for common test setup patterns -- Place shared helpers in `test/helpers/` directory for cross-test reuse -- Use parameter-driven helpers instead of repetitive manual code -- Name helper functions clearly to indicate their purpose -- Example: `create_samples(name, times)` instead of `create_mock_samples_with_time_data()` -- Export helpers as modules: `return { create_samples = create_samples, generate_times = generate_times }` - -## Test Organization and Structure -- **One test file per module**: Each module should have exactly one corresponding test file -- **Mirror module structure**: Test file paths should mirror the module require path - - Module: `require('foo.bar.baz')` → Test: `test/foo/bar/baz_test.lua` - - Module: `require('utils.string')` → Test: `test/utils/string_test.lua` - - Module: `require('utils')` → Test: `test/utils_test.lua` -- Use consistent directory structure that matches the source code organization -- Use descriptive test file names with `_test.lua` suffix -- Organize test functions logically within files (basic cases first, edge cases last) -- Create subdirectories as needed to match the module hierarchy - -## Data Generation and Test Data -- Use helper functions for generating test data consistently -- Parameterize data generation to avoid hardcoded loops -- Example: `generate_times(base_ms, count, variance)` for time arrays -- Keep data generation functions simple and focused -- Use factories for complex object creation -- Prefer deterministic test data over random data when possible - -## Test Categories and Coverage -- **Unit Tests**: Test individual functions/modules in isolation -- **Integration Tests**: Test interaction between components -- **Edge Case Tests**: Test boundary conditions and error scenarios -- **Performance Tests**: Verify performance characteristics when needed -- Aim for high code coverage but focus on meaningful test scenarios -- Test both positive and negative cases (success and failure paths) - -## Test Independence and Isolation -- Each test should be independent and not rely on other tests -- Use setup/teardown functions when needed for test isolation -- Avoid shared mutable state between tests -- Mock external dependencies to ensure test reliability -- Use fresh data/objects for each test to prevent interference - -## Assertion Best Practices -- Use specific assertions that clearly indicate what is being tested -- Provide meaningful error messages in assertions -- Test one concept per assertion when possible -- Group related assertions logically -- Use custom assertion helpers for complex validations - -## Error Testing and Exception Handling -- Test both success and failure cases appropriately -- Use pcall for testing expected errors with clear assertions -- Example: `assert(not ok, 'Should error with single sample')` -- Verify error messages contain expected information -- Test error conditions thoroughly, not just happy paths - -## Test Naming Conventions -- **Test Files**: Follow the pattern `_test.lua` where module_path mirrors the require path - - `require('foo.bar.baz')` → `test/foo/bar/baz_test.lua` - - Always use `_test.lua` suffix for test files -- **Test Functions**: Use descriptive names that explain the scenario - - Format: `testcase.what_is_being_tested_and_expected_outcome()` - - Examples: `basic_two_samples()`, `single_sample_error()`, `zero_mean_edge_case()` -- Group related tests with consistent naming patterns -- Avoid generic names like `test1()` or `basic_test()` -- Test function names should be self-documenting - -## Code Quality and Maintainability -- Keep tests DRY (Don't Repeat Yourself) through good helper functions -- Make tests resilient to minor implementation changes -- Use table-driven tests for multiple similar scenarios where appropriate -- Regularly review and refactor test code for clarity and efficiency -- Write self-documenting code that reduces need for comments - -## Performance Considerations -- Minimize redundant operations in test setup -- Use appropriate sample sizes for test reliability vs speed -- Reuse test data where possible without compromising test isolation -- Consider test execution time in CI/CD pipelines -- Use test doubles (mocks/stubs) to improve test speed - -## Lua-Specific Guidelines -- Follow project's Lua formatting rules (4 spaces, single quotes) -- Use ipairs/pairs appropriately for table iteration -- Leverage Lua's table features for clean test data structures -- Use local variables appropriately to avoid global pollution -- Handle Lua-specific error patterns with pcall/xpcall - -## Test Documentation -- Write clear comments for complex test scenarios -- Document test intentions when behavior is not obvious -- Include references to requirements or specifications being tested -- Explain the reasoning behind specific test data choices -- Document known limitations or assumptions in tests - -## Continuous Improvement -- Monitor test execution metrics (speed, flakiness) -- Regular test code reviews focusing on clarity and coverage -- Remove or update obsolete tests when requirements change -- Refactor tests when they become difficult to maintain -- Share testing patterns and practices across the team - -## Quick Reference - -### Test File Structure - -#### testcase framework -```lua --- test/module_name_test.lua --- require('luacov') -- for coverage - -local testcase = require('testcase') -local module = require('module_name') - -function testcase.basic_functionality() - -- Basic positive test case -end -``` - -#### busted framework -```lua --- spec/module_name_spec.lua -require('luacov') -- for coverage - -local module = require('module_name') - -describe('module_name', function() - it('should handle basic functionality', function() - -- Basic positive test case - end) -end) -``` - -### Test Organization Patterns - -**Module testing**: One test file per module, mirroring require path structure -**Integration testing**: Separate `integration/` subdirectory for multi-module tests -**Performance testing**: Use `bench/` subdirectory with `_bench.lua` suffix -**Error testing**: Group error conditions at end of test file for clarity -**Framework choice**: Use either `test/` (testcase) or `spec/` (busted) consistently across project - -### Effective Test Structure Principles - -**Setup hierarchy**: Global setup → per-test setup → test execution → cleanup -**Data generation**: Create reusable factories for complex test objects -**Assertion strategy**: One primary assertion per test function, supporting assertions for context -**Test naming**: Descriptive names indicating input conditions and expected outcomes - -### Coverage Target -- **Target**: 95%+ (aim for 100% when practical) -- Focus on meaningful test scenarios over just line coverage - -### Framework Documentation References -- **testcase**: Refer to https://github.com/mah0x211/lua-testcase for advanced testing patterns -- **busted**: Refer to https://github.com/lunarmodules/busted for BDD-style testing and mocking features - -## Related Files -- @.claude/memories/ldk-commands.md - Commands for running tests (`testcase test/`) -- @.claude/memories/ldk-project-structure.md - Test file organization and naming conventions -- @.claude/memories/ldk-coverage.md - Coverage analysis for Lua test files -- @.claude/memories/ldk-c-coverage.md - Coverage for C extension testing -- @.claude/memories/ldk-task-checklist.md - Testing as part of development workflow -- @.claude/memories/ldk-code-style.md - Code style rules that apply to test files -- @.claude/memories/ldk-technology-stack.md - testcase framework installation and setup \ No newline at end of file diff --git a/.claude/memories/sentry-test-config.md b/.claude/memories/sentry-test-config.md deleted file mode 100644 index 88c28c4..0000000 --- a/.claude/memories/sentry-test-config.md +++ /dev/null @@ -1,20 +0,0 @@ -# Sentry Test Configuration - -## Default Test Organization and Project - -For all Sentry MCP testing and validation: -- **Organization**: `bruno-garcia` -- **Project**: `playground` - -This configuration is used for: -- End-to-end testing with real Sentry events -- MCP server validation of SDK functionality -- Integration testing across all platforms (Love2D, Roblox, etc.) - -## Usage - -When validating events reach Sentry, always use: -``` -organizationSlug: bruno-garcia -projectSlug: playground -``` \ No newline at end of file diff --git a/.claude/memories/teal-coding-standards.md b/.claude/memories/teal-coding-standards.md deleted file mode 100644 index 64ceea6..0000000 --- a/.claude/memories/teal-coding-standards.md +++ /dev/null @@ -1,27 +0,0 @@ -# Teal Coding Standards - -## File Type Guidelines - -**Use Teal (.tl) for:** -- All code under `src/` directory -- Core SDK implementation files -- Platform-specific modules -- Utility functions and libraries - -**Use Lua (.lua) for:** -- Examples under `examples/` directory -- Test files under `spec/` directory -- Build scripts and configuration files -- External integration scripts - -## Migration Tasks - -When .lua files exist under `src/`, they should be converted to .tl files for: -- Better type safety -- Consistent codebase standards -- Improved developer experience -- Automated type checking in CI - -## Implementation - -Always check `src/` directory for any .lua files that need conversion to .tl format. \ No newline at end of file diff --git a/.github/workflows/test-rockspec.yml b/.github/workflows/test-rockspec.yml deleted file mode 100644 index d033ab1..0000000 --- a/.github/workflows/test-rockspec.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Test Rockspec - -# Test LuaRocks installation with 'luarocks install sentry/sentry' - -on: - push: - branches: - - main - - release/* - pull_request: - paths-ignore: - - "**.md" - -jobs: - test-rockspec: - name: Test 'luarocks install sentry/sentry' on clean system - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 - - - name: Install Lua 5.4 - uses: leafo/gh-actions-lua@35bcb06abec04ec87df82e08caa84d545348536e # v10 - with: - luaVersion: '5.4' - - - name: Install LuaRocks - uses: leafo/gh-actions-luarocks@e65774a6386cb4f24e293dca7fc4ff89165b64c5 # v4 - - - name: Test rockspec installation - run: make test-rockspec-clean - - - name: Show LuaRocks path for debugging - if: failure() - run: | - eval "$(luarocks path --local)" - echo "LUA_PATH: $LUA_PATH" - echo "LUA_CPATH: $LUA_CPATH" - ls -la ~/.luarocks/lib/luarocks/rocks-5.*/sentry/ || true \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0ffd91a..048f9b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -107,7 +107,6 @@ jobs: if [ "${{ matrix.os }}" = "ubuntu-latest" ] && [[ "${{ matrix.lua-version }}" == *"luajit"* ]]; then echo "Installing dependencies locally for Ubuntu LuaJIT..." luarocks install --local busted - luarocks install --local tl luarocks install --local lua-cjson luarocks install --local luasocket if [ -n "$OPENSSL_DIR" ]; then diff --git a/.teal-types.d.tl b/.teal-types.d.tl deleted file mode 100644 index 8168a90..0000000 --- a/.teal-types.d.tl +++ /dev/null @@ -1,17 +0,0 @@ -global record cjson - encode: function(table): string - decode: function(string): table -end - -global record socket_http - request: function(table): any, number -end - -global record ltn12 - source: record - string: function(string): any - end - sink: record - table: function({any}): any - end -end \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index ee00c38..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,34 +0,0 @@ -# claude-lua-devkit - -A comprehensive Lua development toolkit for Claude Code, providing memory files, build system templates, and development commands. - -## Project Purpose - -This project creates efficient Lua development-focused resources optimized for Claude Code usage, enabling developers to build production-ready Lua packages with C/C++ extensions. - -## Key Components - -### Memory Files -Specialized guidance for Lua development workflows and best practices. - -### Build System -Complete Makefile-based build system supporting: -- Pure Lua modules -- C/C++ extensions with automatic compiler detection -- Mixed-language projects -- LuaRocks integration -- Cross-platform compatibility - -### Development Commands -Claude Code integration commands for project setup and management. - -## Development Guidelines - -- **Token Efficiency**: Concise, targeted information over verbose documentation -- **Practical Focus**: Immediately actionable patterns and workflows -- **Cross-Platform**: Support macOS, Linux, and other Unix-like systems -- **Standards Compliance**: Follow Lua and LuaRocks conventions - -## Memory References - -@.claude/memories/ldk-index.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index faccd1a..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,279 +0,0 @@ -# Contributing to Sentry Lua SDK - -Thank you for your interest in contributing to the Sentry Lua SDK! This guide will help you get started with development. - -## Development Requirements - -### System Dependencies - -The following system dependencies are required depending on your platform: - -#### Ubuntu/Debian -```bash -sudo apt-get update -sudo apt-get install -y libssl-dev -``` - -#### macOS -```bash -brew install openssl -export OPENSSL_DIR=$(brew --prefix openssl) -``` - -#### Windows -SSL support is typically built-in with recent Lua installations. -MSVC build tools are automatically configured in CI for Lua compilation. - -### Lua Environment - -- **Lua 5.1, 5.2, 5.3, 5.4 or LuaJIT** (all versions supported) -- **LuaRocks** (package manager) - -### Development Dependencies - -Install all development dependencies with: - -```bash -make install -``` - -This will install: -- `busted` - Testing framework -- `tl` - Teal language compiler -- `lua-cjson` - JSON library -- `luasocket` - HTTP client -- `luasec` - SSL/TLS support -- `luacov` - Code coverage -- `luacov-reporter-lcov` - Coverage reporting - -### Documentation Dependencies (Optional) - -For building documentation: - -```bash -make install-all -``` - -This additionally installs: -- `tealdoc` - Documentation generator (requires LuaFileSystem) - -## Building the Project - -### Compile Teal to Lua - -```bash -make build -``` - -This automatically: -1. Creates all necessary build directories -2. Discovers all `.tl` files in `src/sentry/` -3. Compiles them to corresponding `.lua` files in `build/sentry/` - -### Clean Build Artifacts - -```bash -make clean -``` - -## Testing - -### Run Basic Tests - -```bash -make test -``` - -### Run Tests with Coverage - -```bash -make coverage-report -``` - -This generates: -- `luacov.report.out` - Detailed coverage report -- `coverage.info` - LCOV format for external tools - -### Test Coverage Requirements - -- New features should include comprehensive tests -- Maintain or improve existing code coverage -- All tests must pass on Linux, macOS, and Windows - -## Code Style and Quality - -### Teal Language - -This project is written in [Teal](https://github.com/teal-language/tl), a typed dialect of Lua. - -- Use proper type annotations -- Follow existing code patterns -- Maintain type safety across platform modules - -### Linting - -```bash -make lint # Strict linting (may fail on external modules) -make lint-soft # Permissive linting (warnings ignored) -``` - -### Code Organization - -``` -src/sentry/ -├── init.tl # Main SDK entry point -├── version.tl # Centralized version (auto-updated) -├── types.tl # Type definitions -├── core/ # Core SDK functionality -├── utils/ # Utility functions -└── platforms/ # Platform-specific implementations - ├── standard/ # Standard Lua/LuaJIT - ├── nginx/ # OpenResty/nginx - ├── roblox/ # Roblox game platform - ├── love2d/ # LÖVE 2D game engine - ├── defold/ # Defold game engine - ├── redis/ # Redis Lua scripting - └── test/ # Test transport -``` - -## Platform Support - -The SDK supports multiple Lua environments: - -- **Standard Lua** (5.1+) and LuaJIT -- **nginx/OpenResty** - Web server scripting -- **Roblox** - Game development platform -- **LÖVE 2D** - Game engine -- **Defold** - Game engine -- **Redis** - Lua scripting in Redis - -### Adding New Platforms - -1. Create `src/sentry/platforms/newplatform/` -2. Implement required transport modules -3. Add platform detection in `src/sentry/platform_loader.tl` -4. Add comprehensive tests - -## Documentation - -### Generate Documentation - -```bash -make docs -``` - -Generates HTML documentation in `docs/` directory. - -### Serve Documentation Locally - -```bash -make serve-docs -``` - -Starts local server at http://localhost:8000 - -## Version Management - -### Centralized Versioning - -The project uses a centralized version system: - -- **Single source**: `src/sentry/version.tl` -- **Auto-imported**: All transports reference this version -- **Auto-updated**: Bump scripts modify only this file - -### Version Bumping - -Version updates are handled by automated scripts: - -```bash -# Bump to new version (example: 0.0.2) -pwsh scripts/bump-version.ps1 0.0.2 -# or -./scripts/bump-version.sh dummy 0.0.2 -``` - -**Do not manually update version numbers in individual files.** - -## Continuous Integration - -### GitHub Actions Matrix - -The CI runs on: -- **Ubuntu Latest** (Linux) -- **macOS Latest** -- **Windows Latest** - -With Lua versions: -- **Lua 5.1, 5.2, 5.3, 5.4** -- **LuaJIT** - -### CI Requirements - -All contributions must: -1. **Build successfully** on all platforms -2. **Pass all tests** on all platforms -3. **Maintain code coverage** (Linux/macOS only) -4. **Follow code style** guidelines - -### Local CI Simulation - -Test your changes across platforms by running the same commands as CI: - -```bash -make install # Install dependencies -make build # Build project -make coverage-report # Run tests with coverage (Linux/macOS) -# or -make test # Run basic tests (Windows) -``` - -## Submitting Changes - -### Pull Request Process - -1. **Fork** the repository -2. **Create** a feature branch from `main` -3. **Make** your changes following this guide -4. **Test** locally on your platform -5. **Submit** a pull request - -### Pull Request Requirements - -- [ ] All CI checks pass (Linux, macOS, Windows) -- [ ] Tests cover new functionality -- [ ] Code follows existing patterns -- [ ] Documentation updated if needed -- [ ] Version not manually modified - -### Commit Messages - -Use clear, descriptive commit messages: - -``` -Add Redis Lua scripting transport support - -- Implement RedisTransport class -- Add Redis-specific configuration -- Include comprehensive tests -- Update platform loader -``` - -## Getting Help - -- **Issues**: Report bugs or request features via GitHub Issues -- **Discussions**: Join community discussions on GitHub -- **Documentation**: Refer to generated docs and existing code - -## Development Environment Sync - -> **⚠️ Important**: This CONTRIBUTING.md must stay synchronized with `.github/workflows/test.yml`. When updating build requirements or dependencies, update both files. - -### Current Sync Points -- System dependencies (SSL libraries) -- Lua version requirements -- LuaRocks dependencies -- Build and test commands -- Platform-specific considerations - -Last synced: [Current date] with GitHub Actions workflow v1.0 \ No newline at end of file diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md deleted file mode 100644 index f610fba..0000000 --- a/DISTRIBUTION.md +++ /dev/null @@ -1,146 +0,0 @@ -# LuaRocks Distribution Guide - -This document explains how the Sentry Lua SDK is distributed via LuaRocks with automatic Teal compilation. - -## Overview - -The Sentry Lua SDK is written in Teal and compiled to Lua. The rockspec uses a `command` build type to automatically compile Teal sources during installation: - -```lua --- sentry-0.0.4-1.rockspec -build = { - type = "command", - build_command = "make build", - install_command = "mkdir -p $(LUADIR)/sentry && cp -r build/sentry/* $(LUADIR)/sentry/", - -- ... -} -``` - -When users install via `luarocks install sentry/sentry`, LuaRocks: -1. Clones the Git repository at the specified tag -2. Runs `make build` to compile Teal sources to Lua -3. Installs the compiled Lua modules - -## Solution: Command-Based Build - -We use LuaRocks' `command` build type with automatic Teal compilation. - -### Local Development - -```bash -# Test rockspec installation -make test-rockspec - -# Build from source -make build - -# Install locally for development -luarocks make --local -``` - -### CI/CD Integration - -The GitHub Actions workflow in `.github/workflows/test.yml` automatically: -1. Installs Teal compiler and dependencies -2. Builds the Teal sources (`make build`) -3. Tests rockspec installation (`make test-rockspec`) - -### Release Process - -The release process: -1. Creates a git tag matching the version -2. The rockspec references this tag in the `source.tag` field -3. LuaRocks.org can install directly from the git repository - -## Distribution Methods - -### Method 1: LuaRocks.org (Recommended - macOS/Linux) -```bash -# Install from LuaRocks.org (requires Unix-like system for Teal compilation) -luarocks install sentry/sentry -``` -**Note:** Use `sentry/sentry` (not just `sentry`) as the plain `sentry` package is owned by someone else. - -### Method 2: Direct Download (Cross-platform) -For Windows or systems without make/compiler support: -1. Download the latest `sentry-lua-sdk-publish.zip` from GitHub Releases -2. Extract and add to your Lua path -3. No compilation required - contains pre-built Lua files - -### Method 3: Git Source (Development) -```bash -# Install from specific version (Unix-like systems only) -git clone https://github.com/getsentry/sentry-lua.git -cd sentry-lua -make build -luarocks make --local -``` - -## Compatibility - -- **Target**: Lua 5.1 compatibility (`tlconfig.lua`: `gen_target = "5.1"`, `gen_compat = "optional"`) -- **Tested**: Lua 5.1, 5.2, 5.3, 5.4, LuaJIT 2.0, LuaJIT 2.1 -- **Platform**: Pure Lua (no native code currently) - -## Build Process - -``` -Git Repository: -├── src/sentry/ (Teal sources) -│ ├── init.tl -│ ├── core/ -│ ├── platforms/ -│ └── utils/ -├── Makefile (Build system) -└── sentry-0.0.4-1.rockspec - -After `make build`: -├── build/sentry/ (Compiled Lua) -│ ├── init.lua -│ ├── core/ -│ ├── platforms/ -│ └── utils/ - -After `make publish`: -├── sentry-lua-sdk-publish.zip (Direct download package) - ├── build/sentry/ (Pre-compiled Lua files) - ├── examples/ (Usage examples) - ├── README.md - └── CHANGELOG.md -``` - -## Verification - -Test the rockspec installation works: - -```bash -# Test rockspec installation -make test-rockspec - -# Manual verification -luarocks make --local -eval "$(luarocks path --local)" -lua -e "local sentry = require('sentry'); print('✅ Success')" -``` - -## Advantages - -✅ **Automatic compilation** - Teal sources compiled during installation -✅ **Standard LuaRocks** - Uses official LuaRocks distribution methods -✅ **Git tag-based** - Proper versioning with git tags -✅ **Lua 5.1 compatible** - Works across all Lua versions -✅ **CI tested** - Installation process tested in CI - -## Maintenance - -- Rockspec tested automatically in CI across multiple Lua versions (5.1, 5.2, 5.3, 5.4, LuaJIT) -- Separate GitHub Actions workflow tests clean system installation -- `make test-rockspec` target for local testing with existing dependencies -- `make test-rockspec-clean` target for testing on clean systems (installs Teal automatically) -- Version bumping updates both rockspec version and git tag - -### Security - -- All GitHub Actions use pinned commit SHAs (not version tags) for security -- See `.claude/memories/github-actions-security.md` for SHA reference -- Actions are verified and updated following security best practices \ No newline at end of file diff --git a/Makefile b/Makefile deleted file mode 100644 index 782af7c..0000000 --- a/Makefile +++ /dev/null @@ -1,320 +0,0 @@ -.PHONY: build test test-coverage coverage-report test-love clean install install-teal docs install-love2d ci-love2d test-rockspec test-rockspec-clean publish roblox-all-in-one - -# Install Teal compiler (for fresh systems without Teal) -install-teal: - @if ! command -v tl > /dev/null 2>&1; then \ - echo "Installing Teal compiler..."; \ - luarocks install --local tl; \ - eval "$$(luarocks path --local)"; \ - else \ - echo "Teal compiler already available"; \ - fi - -# Build Teal files to Lua -build: install-teal - rm -rf build/ - find src/sentry -type d | sed 's|src/sentry|build/sentry|' | xargs mkdir -p - find src/sentry -name "*.tl" -type f | while read -r tl_file; do \ - lua_file=$$(echo "$$tl_file" | sed 's|src/sentry|build/sentry|' | sed 's|\.tl$$|.lua|'); \ - echo "Compiling $$tl_file -> $$lua_file"; \ - eval "$$(luarocks path --local)" && tl gen "$$tl_file" -o "$$lua_file" || exit 1; \ - done - -# Run unit tests -test: build - busted - -# Run unit tests with coverage -test-coverage: build - rm -f luacov.*.out test-results.xml - LUA_PATH="build/?.lua;build/?/init.lua;;" busted --coverage - luacov - @# Generate test results in JUnit XML format for codecov test analytics - LUA_PATH="build/?.lua;build/?/init.lua;;" busted --output=junit > test-results.xml - -# Run Love2D tests (requires Love2D to be installed) -test-love: build - @echo "Running Love2D unit tests with busted..." - LUA_PATH="build/?.lua;build/?/init.lua;;" busted spec/platforms/love2d/love2d_spec.lua --output=TAP - @echo "" - @echo "Running Love2D integration tests (headless)..." - @# Verify lua-https binary is available - @if [ ! -f examples/love2d/https.so ]; then \ - echo "❌ CRITICAL: lua-https binary not found at examples/love2d/https.so"; \ - echo "Love2D tests REQUIRE HTTPS support. Rebuild with:"; \ - echo "cd examples/love2d/lua-https && cmake -Bbuild -S. -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=\$$PWD/install && cmake --build build --target install"; \ - exit 1; \ - fi - @# Copy binary to test directory - cp examples/love2d/https.so spec/platforms/love2d/ || { \ - echo "❌ Failed to copy https.so to test directory"; \ - exit 1; \ - } - @# Run Love2D integration tests with cross-platform timeout and virtual display - @if [ "$(shell uname)" = "Darwin" ]; then \ - echo "Running Love2D tests on macOS (no timeout command available)"; \ - cd spec/platforms/love2d && love . > test_output.log 2>&1 || true; \ - else \ - echo "Running Love2D tests with virtual display on Linux"; \ - cd spec/platforms/love2d && xvfb-run -a -s "-screen 0 1x1x24" timeout 30s love . > test_output.log 2>&1 || true; \ - fi - @# Validate test results - @if grep -q "All tests passed" spec/platforms/love2d/test_output.log; then \ - echo "✅ Love2D integration tests passed"; \ - cat spec/platforms/love2d/test_output.log; \ - else \ - echo "❌ Love2D integration tests failed or incomplete"; \ - cat spec/platforms/love2d/test_output.log; \ - rm -f spec/platforms/love2d/test_output.log spec/platforms/love2d/https.so; \ - exit 1; \ - fi - @# Clean up test artifacts - @rm -f spec/platforms/love2d/test_output.log spec/platforms/love2d/https.so - @echo "✅ All Love2D tests completed successfully" - -# Generate coverage report in LCOV format for codecov -coverage-report: test-coverage - @# Generate LCOV; do not swallow errors, and ensure non-empty output - @rm -f coverage.info - @echo "Generating LCOV with luacov.reporter.lcov ..." - @lua -e "require('luacov.reporter.lcov').report()" > coverage.info || true - @if [ ! -s coverage.info ]; then \ - echo "coverage.info is empty after luacov.reporter.lcov; trying 'luacov -r lcov'"; \ - luacov -r lcov > coverage.info 2>/dev/null || true; \ - fi - @if [ ! -s coverage.info ]; then \ - echo "LCOV generation still empty; falling back to raw luacov report"; \ - cp -f luacov.report.out coverage.info 2>/dev/null || true; \ - fi - @# Print quick stats to help debug in CI - @echo "File stats (lines words bytes):"; \ - ( [ -f coverage.info ] && echo "coverage.info:" && wc -l -w -c coverage.info ) || true; \ - ( [ -f luacov.report.out ] && echo "luacov.report.out:" && wc -l -w -c luacov.report.out ) || true; \ - ( [ -f luacov.stats.out ] && echo "luacov.stats.out:" && wc -l -w -c luacov.stats.out ) || true - @# Show SF lines BEFORE path remapping - @echo "SF lines BEFORE remap:"; \ - grep "^SF:" coverage.info || true - @# Map build/*.lua file paths (absolute or relative) to src/*.tl for Codecov - @# Handle both absolute and relative paths that contain build/sentry - @sed -i.bak 's|^SF:.*build/sentry/\(.*\)\.lua|SF:src/sentry/\1.tl|g' coverage.info - @# Show SF lines AFTER path remapping - @echo "SF lines AFTER remap:"; \ - grep "^SF:" coverage.info || true - @# Verify that mapped source files actually exist - @echo "Verifying coverage file paths..." - @grep "^SF:" coverage.info | sed 's/^SF://' | while read -r file; do \ - if [ ! -f "$$file" ]; then \ - echo "Warning: Source file $$file not found in repository"; \ - fi; \ - done - @# Clean up sed backup and finish - @rm -f coverage.info.bak - @echo "Coverage report generated at coverage.info" - -# Clean build artifacts -clean: - rm -rf build/ - rm -f luacov.*.out coverage.info test-results.xml - -# Install dependencies -install: - luarocks install busted - luarocks install tl - luarocks install lua-cjson - luarocks install luasocket - @if [ -n "$$OPENSSL_DIR" ]; then \ - echo "Installing luasec with OPENSSL_DIR=$$OPENSSL_DIR"; \ - luarocks install luasec OPENSSL_DIR=$$OPENSSL_DIR; \ - else \ - luarocks install luasec; \ - fi - luarocks install luacov - luarocks install luacov-reporter-lcov - -# Install all dependencies including docs tools -install-all: install - luarocks install tealdoc - -# Generate documentation -docs: build install-all - mkdir -p docs - tealdoc html -o docs --all src/sentry/*.tl src/sentry/core/*.tl src/sentry/utils/*.tl src/sentry/platforms/**/*.tl - -# Lint code (strict) -lint: - tl check src/sentry/init.tl - tl check src/sentry/core/context.tl - tl check src/sentry/core/transport.tl - tl check src/sentry/core/test_transport.tl - tl check src/sentry/core/file_io.tl - tl check src/sentry/core/file_transport.tl - tl check src/sentry/core/client.tl - tl check src/sentry/utils/*.tl - tl check src/sentry/platforms/**/*.tl - -# Lint code (permissive - ignore external module warnings) -lint-soft: - -tl check src/sentry/init.tl - -tl check src/sentry/core/context.tl - -tl check src/sentry/core/transport.tl - -tl check src/sentry/core/test_transport.tl - -tl check src/sentry/core/file_io.tl - -tl check src/sentry/core/file_transport.tl - -tl check src/sentry/core/client.tl - -tl check src/sentry/utils/*.tl - -tl check src/sentry/platforms/**/*.tl - @echo "Soft lint completed (warnings ignored)" - -# Docker tests -docker-test-redis: - docker-compose -f docker/redis/docker-compose.yml up --build --abort-on-container-exit - -docker-test-nginx: - docker-compose -f docker/nginx/docker-compose.yml up --build --abort-on-container-exit - -# Full test suite (excludes Love2D - requires Love2D installation) -test-all: test test-rockspec docker-test-redis docker-test-nginx - -# Install Love2D (platform-specific) -install-love2d: - @echo "Installing Love2D..." - @if [ "$$(uname)" = "Darwin" ]; then \ - echo "Installing Love2D on macOS..."; \ - if ! command -v love > /dev/null 2>&1; then \ - if command -v brew > /dev/null 2>&1; then \ - brew install --cask love; \ - else \ - echo "❌ Homebrew not found. Please install Homebrew first."; \ - exit 1; \ - fi; \ - else \ - echo "✅ Love2D already installed: $$(love --version)"; \ - fi; \ - elif [ "$$(uname)" = "Linux" ]; then \ - echo "Installing Love2D and virtual display on Linux..."; \ - if ! command -v love > /dev/null 2>&1; then \ - if command -v apt-get > /dev/null 2>&1; then \ - sudo add-apt-repository -y ppa:bartbes/love-stable; \ - sudo apt-get update; \ - sudo apt-get install -y love; \ - else \ - echo "❌ apt-get not found. Please install Love2D manually."; \ - exit 1; \ - fi; \ - else \ - echo "✅ Love2D already installed: $$(love --version)"; \ - fi; \ - else \ - echo "❌ Unsupported platform: $$(uname)"; \ - exit 1; \ - fi - @echo "✅ Love2D installation complete" - -# CI target for Love2D - install Love2D and run tests -ci-love2d: install-love2d build test-love - -# Full test suite including Love2D (requires Love2D to be installed) -test-all-with-love: test test-rockspec test-love docker-test-redis docker-test-nginx - -# Serve documentation locally -serve-docs: docs - @echo "Starting documentation server at http://localhost:8000" - @echo "Press Ctrl+C to stop" - python3 -m http.server 8000 --directory docs - -# Test rockspec by installing it in an isolated environment -test-rockspec: build - @echo "Testing rockspec installation and functionality..." - @rm -rf rockspec-test/ - @mkdir -p rockspec-test - @# Copy current rockspec and source files to test directory - @find . -maxdepth 1 -name "*.rockspec" -exec cp {} rockspec-test/ \; - @cp -r src rockspec-test/ - @cp tlconfig.lua rockspec-test/ 2>/dev/null || true - @# Create a minimal test application that only tests module loading - @echo 'local sentry = require("sentry")' > rockspec-test/test.lua - @echo 'print("✅ Sentry loaded successfully")' >> rockspec-test/test.lua - @echo '-- Test that we can access core functions without initializing' >> rockspec-test/test.lua - @echo 'if type(sentry.init) == "function" then' >> rockspec-test/test.lua - @echo ' print("✅ Sentry API available")' >> rockspec-test/test.lua - @echo 'end' >> rockspec-test/test.lua - @# Install the rockspec locally - @echo "Installing rockspec locally..." - @cd rockspec-test && find . -maxdepth 1 -name "*.rockspec" -exec luarocks make --local {} \; - @# Test basic functionality - @echo "Testing basic Sentry functionality..." - @cd rockspec-test && eval "$$(luarocks path --local)" && lua test.lua - @# Clean up - @echo "Cleaning up test environment..." - @rm -rf rockspec-test/ - @echo "✅ Rockspec validation completed successfully" - -# Test rockspec installation on a clean system (for CI) -test-rockspec-clean: - @echo "Testing rockspec installation on clean system..." - @rm -rf rockspec-clean-test/ - @mkdir -p rockspec-clean-test - @# Copy current rockspec to test directory - @find . -maxdepth 1 -name "*.rockspec" -exec cp {} rockspec-clean-test/ \; - @# Copy source files (needed for build) - @cp -r src rockspec-clean-test/ - @cp tlconfig.lua rockspec-clean-test/ 2>/dev/null || true - @# Create a minimal test application that only tests module loading - @echo 'local sentry = require("sentry")' > rockspec-clean-test/test.lua - @echo 'print("✅ Sentry loaded successfully")' >> rockspec-clean-test/test.lua - @echo '-- Test that we can access core functions without initializing' >> rockspec-clean-test/test.lua - @echo 'if type(sentry.init) == "function" then' >> rockspec-clean-test/test.lua - @echo ' print("✅ Sentry API available")' >> rockspec-clean-test/test.lua - @echo 'end' >> rockspec-clean-test/test.lua - @# Install build dependencies first - @echo "Installing build dependencies..." - @cd rockspec-clean-test && luarocks install --local tl - @echo "Installing sentry rockspec with all dependencies..." - @cd rockspec-clean-test && find . -maxdepth 1 -name "*.rockspec" -exec echo "Found rockspec: {}" \; - @cd rockspec-clean-test && find . -maxdepth 1 -name "*.rockspec" -exec luarocks make --local --verbose {} \; || { echo "❌ Rockspec installation failed"; luarocks list --local; exit 1; } - @echo "Verifying sentry module installation..." - @cd rockspec-clean-test && eval "$$(luarocks path --local)" && lua -e "require('sentry'); print('✅ Sentry module found')" || { echo "❌ Sentry module not found after installation"; exit 1; } - @# Test functionality - @echo "Testing Sentry functionality..." - @cd rockspec-clean-test && eval "$$(luarocks path --local)" && lua test.lua - @# Clean up - @echo "Cleaning up test environment..." - @rm -rf rockspec-clean-test/ - @echo "✅ Clean system rockspec validation completed successfully" - -# Create publish package for direct download (Windows/cross-platform) -# Contains pre-compiled Lua files, no LuaRocks or compilation required -publish: build - @echo "Creating publish package for direct download (Windows/cross-platform)..." - @echo "This package contains pre-compiled Lua files and does not require LuaRocks or compilation." - @rm -f sentry-lua-sdk-publish.zip - @# Create temporary directory for packaging - @mkdir -p publish-temp - @# Copy required files - @cp README.md publish-temp/ || { echo "❌ README.md not found"; exit 1; } - @cp example-event.png publish-temp/ || { echo "❌ example-event.png not found"; exit 1; } - @cp CHANGELOG.md publish-temp/ || { echo "❌ CHANGELOG.md not found"; exit 1; } - @cp roblox.json publish-temp/ || { echo "❌ roblox.json not found"; exit 1; } - @# Copy build directory (recursively) - @cp -r build publish-temp/ || { echo "❌ build directory not found. Run 'make build' first."; exit 1; } - @# Copy examples directory (recursively) - @cp -r examples publish-temp/ || { echo "❌ examples directory not found"; exit 1; } - @# Create zip file - @cd publish-temp && zip -r ../sentry-lua-sdk-publish.zip . > /dev/null - @# Clean up temporary directory - @rm -rf publish-temp - @echo "✅ Publish package created: sentry-lua-sdk-publish.zip" - @echo "📦 This package is for direct download installation (Windows/cross-platform)" - @echo "📦 Contains pre-compiled Lua files - no LuaRocks or compilation required" - @echo "📦 Upload to GitHub Releases for user download" - @echo "" - @echo "Package contents:" - @unzip -l sentry-lua-sdk-publish.zip - -# Validate Roblox all-in-one integration file -roblox-all-in-one: build - @echo "Validating Roblox all-in-one integration..." - @./scripts/generate-roblox-all-in-one.sh - @echo "✅ Validated examples/roblox/sentry-all-in-one.lua" - @echo "📋 This file contains the complete SDK and can be copy-pasted into Roblox Studio" - diff --git a/README.md b/README.md index 2e6ea44..2f54a60 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,26 @@ # Sentry SDK for Lua -> NOTE: This is a hackweek project and not officially supported by Sentry. -> Not all platforms are yet tested. -> -> We accept PRs if you're willing to fix bugs and add features. -> If there's enough interest, we could invest more into this. +> NOTE: Experimental SDK [![Tests](https://github.com/getsentry/sentry-lua/actions/workflows/test.yml/badge.svg)](https://github.com/getsentry/sentry-lua/actions/workflows/test.yml) [![codecov](https://codecov.io/gh/getsentry/sentry-lua/branch/main/graph/badge.svg)](https://codecov.io/gh/getsentry/sentry-lua) -A platform-agnostic Sentry SDK for Lua environments. Written in Teal Language for better type safety and developer experience. - The goal of this SDK is to be *portable* Lua code, so CI/tests run on Standard Lua, as well as LuaJIT, which can run on [Game Consoles](https://luajit.org/status.html#consoles), one of [Sentry's latest platform investments](https://blog.sentry.io/playstation-xbox-switch-pc-or-mobile-wherever-youve-got-bugs-to-crush-sentry/). -![Screenshot of this example app](./example-event.png "Example Lua error with source context, logs and trace connected") - -## Features - -- **Platform Agnostic**: Works across Redis, nginx, Roblox, game engines, and standard Lua -- **Type Safe**: Written in Teal Language with full type definitions -- **Comprehensive**: Error tracking, breadcrumbs, context management, and scoped operations -- **Performance Monitoring**: Object-oriented API for transactions and spans with distributed tracing -- **Distributed Tracing**: Automatic trace propagation across service boundaries via HTTP headers -- **Structured Logging**: Built-in logging with batching, print hooks, and trace correlation -- **Extensible**: Pluggable transport system for different environments - -## Supported Platforms - -- Standard Lua 5.1+ environments -- Roblox game platform -- LÖVE 2D game engine -- Solar2D/Corona SDK -- Defold game engine -- Redis (lua scripting) -- nginx/OpenResty - ## Installation ### LuaRocks (macOS/Linux) ```bash # Install from LuaRocks.org - requires Unix-like system for Teal compilation -luarocks install sentry/sentry +luarocks install sentry/sdk ``` -**Note:** Use `sentry/sentry` (not just `sentry`) as the plain `sentry` package is owned by someone else. +**Note:** Use `sentry/sdk` (not just `sentry`) as the plain `sentry` package is not a Sentry SDK. ### Direct Download (Windows/Cross-platform) For Windows or systems without make/compiler support: 1. Download the latest `sentry-lua-sdk-publish.zip` from [GitHub Releases](https://github.com/getsentry/sentry-lua/releases) 2. Extract the contents -3. Add the `build/sentry` directory to your Lua `package.path` -4. No compilation required - contains pre-built Lua files - -```lua --- Example usage after extracting to project directory -package.path = package.path .. ";./build/?.lua;./build/?/init.lua" -local sentry = require("sentry") -``` - -### Development (From Source) -```bash -# Clone the repository and build from source -git clone https://github.com/getsentry/sentry-lua.git -cd sentry-lua -make install # Install build dependencies -make build # Compile Teal sources to Lua -luarocks make --local # Install locally -``` - -### Platform-Specific Instructions - -#### Roblox -Import the module through the Roblox package system or use the pre-built releases. - -#### LÖVE 2D -The SDK automatically detects the Love2D environment and uses the lua-https module for reliable HTTPS transport. Use the direct download method and copy the SDK files into your Love2D project: - -```lua --- main.lua -local sentry = require("sentry") -local logger = require("sentry.logger") - -function love.load() - sentry.init({ - dsn = "https://your-dsn@sentry.io/project-id", - environment = "love2d", - release = "0.0.6" - }) - - -- Optional: Enable logging integration - logger.init({ - enable_logs = true, - max_buffer_size = 10, - flush_timeout = 5.0 - }) -end - -function love.update(dt) - -- Flush transport periodically - sentry.flush() -end - -function love.quit() - -- Clean shutdown - sentry.close() -end -``` - -**HTTPS Support**: The Love2D example includes a pre-compiled `https.so` binary from [lua-https](https://github.com/love2d/lua-https) for reliable SSL/TLS support. This binary is committed to the repository for convenience. If you need to rebuild it for your platform: - -```bash -cd examples/love2d/lua-https -cmake -Bbuild -S. -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=$PWD/install -cmake --build build --target install -# Copy install/https.so to your Love2D project -``` - -See `examples/love2d/` for a complete interactive demo with error triggering and visual feedback. ## Quick Start @@ -145,7 +49,7 @@ if not success then }) end --- Flush pending events immediately +-- Flush pending events sentry.flush() -- Clean shutdown @@ -166,253 +70,6 @@ sentry.close() - `sentry.with_scope(callback)` - Execute code with isolated scope - `sentry.wrap(main_function, error_handler)` - Wrap function with error handling -## Distributed Tracing - -The Sentry Lua SDK supports distributed tracing to improve debuggability across app and service boundaries. -Traces let you see how telemetry (errors/events, logs, etc) from different processes links together, making it easier to follow execution paths and uncover issues. -In addition, through spans, they highlight performance characteristics of your application, helping you detect and resolve bottlenecks. - -### Requirements - -For the distributed tracing examples, you'll need additional dependencies: - -```bash -luarocks install pegasus # HTTP server framework -luarocks install luasocket # HTTP client library -``` - -### Basic Tracing - -```lua -local sentry = require("sentry") -local performance = require("sentry.performance") - -sentry.init({ - dsn = "https://your-dsn@sentry.io/project-id" -}) - --- Start a transaction -local transaction = performance.start_transaction("user_checkout", "http.server") - --- Add spans for different operations -local validation_span = transaction:start_span("validation", "Validate cart") --- ... validation logic ... -validation_span:finish("ok") - -local payment_span = transaction:start_span("payment.charge", "Process payment") --- ... payment logic ... -payment_span:finish("ok") - --- Finish the transaction -transaction:finish("ok") -``` - -### Advanced Tracing Features - -#### Nested Spans and Context Management - -```lua -local transaction = performance.start_transaction("api_request", "http.server") - --- Nested spans automatically maintain parent-child relationships -local db_span = transaction:start_span("db.query", "Get user data") -local user_id = get_user_from_db() -db_span:finish("ok") - --- Spans can be nested further -local processing_span = transaction:start_span("processing", "Process user data") - -local validation_span = processing_span:start_span("validation", "Validate permissions") -validate_user_permissions(user_id) -validation_span:finish("ok") - -local enrichment_span = processing_span:start_span("enrichment", "Enrich user data") -enrich_user_data(user_id) -enrichment_span:finish("ok") - -processing_span:finish("ok") -transaction:finish("ok") -``` - -#### Adding Context and Tags - -```lua -local transaction = performance.start_transaction("checkout", "business.process") - --- Add tags and data to transactions -transaction:add_tag("user_type", "premium") -transaction:add_tag("payment_method", "credit_card") -transaction:add_data("cart_items", 3) -transaction:add_data("total_amount", 99.99) - -local span = transaction:start_span("payment.process", "Charge credit card") --- Add context to individual spans -span:add_tag("processor", "stripe") -span:add_data("amount", 99.99) -span:add_data("currency", "USD") - --- Finish with status -span:finish("ok") -- or "error", "cancelled", etc. -transaction:finish("ok") -``` - -### Distributed Tracing Examples - -The SDK includes complete examples demonstrating distributed tracing: - -- `examples/tracing/client.lua` - HTTP client that propagates trace context -- `examples/tracing/server.lua` - HTTP server with distributed tracing endpoints - -To see distributed tracing in action: - -1. Start the server: `lua examples/tracing/server.lua` -2. In another terminal, run the client: `lua examples/tracing/client.lua` -3. Check your Sentry dashboard to see connected traces across both processes - -The examples demonstrate: -- Object-oriented API with method chaining on transactions and spans -- Automatic trace context propagation via HTTP headers (`sentry-trace`, `baggage`) -- Proper parent-child span relationships across service boundaries -- Error correlation within distributed traces -- Performance monitoring with detailed timing information - -#### Manual Trace Propagation - -For custom HTTP implementations or other transport mechanisms: - -```lua -local tracing = require("sentry.tracing") - --- Get trace headers for outgoing requests -local headers = tracing.get_request_headers("https://api.example.com") --- headers will contain sentry-trace and baggage headers - --- Continue trace from incoming headers on the receiving side -local incoming_headers = { - ["sentry-trace"] = "abc123-def456-1", - ["baggage"] = "user_id=12345,environment=prod" -} -tracing.continue_trace_from_request(incoming_headers) - --- Now start transaction with continued trace context -local transaction = performance.start_transaction("api_handler", "http.server") --- This transaction will be part of the distributed trace -``` - -**Benefits of the new API:** -- **No global state**: Each transaction and span is explicitly managed -- **Better control**: Clear ownership of when operations start and finish -- **Proper nesting**: Spans automatically inherit from their parent transaction/span -- **Type safety**: Full Teal type definitions for all methods -- **Easier debugging**: No hidden global state to track - -## Structured Logging - -The Sentry Lua SDK provides comprehensive logging capabilities with automatic batching, trace correlation, and print statement capture. - -### Basic Logging - -```lua -local sentry = require("sentry") -local logger = require("sentry.logger") - -sentry.init({ - dsn = "https://your-dsn@sentry.io/project-id", - _experiments = { - enable_logs = true, - hook_print = true -- Automatically capture print() calls - } -}) - --- Initialize logger -logger.init({ - enable_logs = true, - max_buffer_size = 100, - flush_timeout = 5.0, - hook_print = true -}) - --- Log at different levels -logger.trace("Fine-grained debugging information") -logger.debug("Debugging information") -logger.info("General information") -logger.warn("Warning message") -logger.error("Error occurred") -logger.fatal("Critical failure") -``` - -### Structured Logging with Parameters - -```lua --- Parameterized messages for better searchability -local user_id = "user_123" -local order_id = "order_456" - -logger.info("User %s placed order %s", {user_id, order_id}) -logger.error("Payment failed for user %s with error %s", {user_id, "CARD_DECLINED"}) - --- With additional attributes -logger.info("Order completed successfully", {user_id, order_id}, { - order_total = 149.99, - payment_method = "credit_card", - shipping_type = "express", - processing_time = 1.2 -}) -``` - -### Automatic Print Capture - -```lua --- These print statements are automatically captured as logs -print("Application started") -- Becomes info-level log -print("Debug:", user_id, order_id) -- Multiple arguments handled - --- No infinite loops - Sentry's own print statements are ignored -``` - -### Log Correlation with Traces - -```lua -local transaction = performance.start_transaction("checkout", "business_process") - --- Logs within transactions are automatically correlated -logger.info("Starting checkout process") - -local span = transaction:start_span("validation", "Validate cart") -logger.debug("Validating cart for user %s", {user_id}) -span:finish("ok") - -logger.warn("Payment processor slow: %sms", {2100}) -transaction:finish("ok") - --- All logs will have trace_id linking them to the transaction -``` - -### Advanced Configuration - -```lua -logger.init({ - enable_logs = true, - max_buffer_size = 50, -- Batch up to 50 logs - flush_timeout = 10.0, -- Flush every 10 seconds - hook_print = true, -- Capture print statements - - before_send_log = function(log_record) - -- Filter sensitive information - if log_record.body:find("password") then - return nil -- Don't send this log - end - - -- Add custom attributes - log_record.attributes["custom_field"] = { - value = "custom_value", - type = "string" - } - - return log_record - end -}) -``` ## Automatic Error Capture @@ -463,36 +120,3 @@ For example, the LÖVE framework example app: ![Screenshot of this example app](./examples/love2d/example-app.png "LÖVE Example App") -## Development - -### Prerequisites -- Teal Language compiler -- busted (for testing) -- Docker (for integration tests) - -### Building -```bash -make install # Install dependencies -make build # Compile Teal to Lua -make test # Run unit tests -make docs # Generate documentation -make serve-docs # Serve docs at http://localhost:8000 -make lint-soft # Lint with warnings (permissive) -``` - -### Testing -```bash -# Unit tests -make test - -# Docker integration tests -make docker-test-redis -make docker-test-nginx - -# Full test suite -make test-all -``` - -## License - -MIT License - see LICENSE file for details. diff --git a/codecov.yml b/codecov.yml index ae1aba8..e1102e0 100644 --- a/codecov.yml +++ b/codecov.yml @@ -14,9 +14,7 @@ coverage: ignore: - "spec/" - "examples/" - - "build/" - "*.rockspec" - - "Makefile" - "*.md" parsers: diff --git a/docker/nginx/Dockerfile b/docker/nginx/Dockerfile deleted file mode 100644 index 2daad11..0000000 --- a/docker/nginx/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -FROM openresty/openresty:alpine - -RUN apk add --no-cache \ - luarocks \ - build-base \ - unzip - -RUN luarocks install lua-cjson -RUN luarocks install lua-resty-http - -WORKDIR /app - -COPY . . -COPY docker/nginx/nginx.conf /usr/local/openresty/nginx/conf/nginx.conf -COPY docker/nginx/test.lua /usr/local/openresty/nginx/html/test.lua - -RUN if [ -f tlconfig.lua ]; then \ - luarocks install tl && \ - tl build; \ - fi - -EXPOSE 80 - -CMD ["/usr/local/openresty/bin/openresty", "-g", "daemon off;"] \ No newline at end of file diff --git a/docker/nginx/docker-compose.yml b/docker/nginx/docker-compose.yml deleted file mode 100644 index 07c193b..0000000 --- a/docker/nginx/docker-compose.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: '3.8' - -services: - nginx: - build: - context: ../.. - dockerfile: docker/nginx/Dockerfile - ports: - - "8080:80" - volumes: - - ../../:/app - working_dir: /app \ No newline at end of file diff --git a/docker/nginx/nginx.conf b/docker/nginx/nginx.conf deleted file mode 100644 index fe56953..0000000 --- a/docker/nginx/nginx.conf +++ /dev/null @@ -1,21 +0,0 @@ -worker_processes 1; -error_log logs/error.log; -events { - worker_connections 1024; -} - -http { - lua_package_path "/app/build/?.lua;/app/src/?.lua;;"; - - server { - listen 80; - - location /test { - content_by_lua_file /usr/local/openresty/nginx/html/test.lua; - } - - location /health { - return 200 "OK"; - } - } -} \ No newline at end of file diff --git a/docker/nginx/test.lua b/docker/nginx/test.lua deleted file mode 100644 index f979c9e..0000000 --- a/docker/nginx/test.lua +++ /dev/null @@ -1,43 +0,0 @@ -local sentry = require("sentry.init") - -ngx.say("Testing Sentry SDK with nginx/OpenResty integration...") - -local sentry_client = sentry.init({ - dsn = "https://test@sentry.io/123456", - environment = "docker-nginx-test", - debug = true -}) - -ngx.say("✓ Sentry initialized") - -sentry.set_tag("platform", "nginx") -sentry.set_tag("server", ngx.var.server_name or "localhost") -sentry.set_extra("request_uri", ngx.var.request_uri) -sentry.set_extra("user_agent", ngx.var.http_user_agent) - -sentry.add_breadcrumb({ - message = "HTTP request received", - category = "http", - level = "info", - data = { - method = ngx.var.request_method, - uri = ngx.var.request_uri - } -}) - -local event_id = sentry.capture_message("Hello from nginx integration!", "info") -ngx.say("✓ Message captured: " .. event_id) - -local success, err = pcall(function() - error("Test nginx error") -end) - -if not success then - local exception_id = sentry.capture_exception({ - type = "NginxTestError", - message = err - }) - ngx.say("✓ Exception captured: " .. exception_id) -end - -ngx.say("✓ nginx integration test completed successfully!") \ No newline at end of file diff --git a/docker/redis/Dockerfile b/docker/redis/Dockerfile deleted file mode 100644 index 797ee45..0000000 --- a/docker/redis/Dockerfile +++ /dev/null @@ -1,19 +0,0 @@ -FROM openresty/openresty:alpine - -RUN apk add --no-cache \ - luarocks \ - build-base \ - unzip - -RUN luarocks install lua-cjson -RUN luarocks install luasocket -RUN luarocks install redis-lua - -WORKDIR /app - -COPY . . - -RUN if [ -f tlconfig.lua ]; then \ - luarocks install tl && \ - tl build; \ - fi \ No newline at end of file diff --git a/docker/redis/docker-compose.yml b/docker/redis/docker-compose.yml deleted file mode 100644 index 520cb02..0000000 --- a/docker/redis/docker-compose.yml +++ /dev/null @@ -1,22 +0,0 @@ -version: '3.8' - -services: - redis: - image: redis:7-alpine - ports: - - "6379:6379" - command: redis-server --appendonly yes - - sentry-test: - build: - context: ../.. - dockerfile: docker/redis/Dockerfile - depends_on: - - redis - environment: - - REDIS_HOST=redis - - REDIS_PORT=6379 - volumes: - - ../../:/app - working_dir: /app - command: lua docker/redis/test.lua \ No newline at end of file diff --git a/docker/redis/test.lua b/docker/redis/test.lua deleted file mode 100644 index d57d236..0000000 --- a/docker/redis/test.lua +++ /dev/null @@ -1,207 +0,0 @@ -local redis = require("redis") -local sentry = require("sentry.init") - -print("=== Testing Sentry SDK with Redis Lua Scripts ===") - --- Connect to Redis -local client = redis.connect("redis", 6379) -if not client then - print("❌ Failed to connect to Redis") - os.exit(1) -end - -print("✓ Connected to Redis") - --- Initialize Sentry -local sentry_client = sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - environment = "docker-redis-test", - debug = true -}) - -print("✓ Sentry initialized") - --- Set tags and context -sentry.set_tag("platform", "redis") -sentry.set_tag("test_type", "lua_script") -sentry.set_extra("redis_info", "Docker test with Lua scripts") - -sentry.add_breadcrumb({ - message = "Redis connection established", - category = "redis", - level = "info" -}) - --- Test 1: Basic Redis operations with Sentry tracking -print("\n--- Test 1: Basic Redis Operations ---") -sentry.add_breadcrumb({ - message = "Starting basic Redis operations test", - category = "test", - level = "info" -}) - -client:set("test:key", "hello_redis") -local value = client:get("test:key") -print("✓ SET/GET test: " .. (value or "nil")) - --- Test 2: Load and execute a Lua script in Redis -print("\n--- Test 2: Redis Lua Script Execution ---") -sentry.add_breadcrumb({ - message = "Loading Lua script into Redis", - category = "redis", - level = "info" -}) - --- Lua script that increments a counter and logs activity -local lua_script = [[ - local key = KEYS[1] - local increment = tonumber(ARGV[1]) or 1 - local current = redis.call('GET', key) - - if not current then - current = 0 - else - current = tonumber(current) - end - - local new_value = current + increment - redis.call('SET', key, new_value) - - -- Log the operation - local log_key = key .. ':log' - redis.call('LPUSH', log_key, 'Incremented from ' .. current .. ' to ' .. new_value) - redis.call('EXPIRE', log_key, 300) -- Expire after 5 minutes - - return {new_value, current} -]] - --- Load the script and get its SHA1 hash -local script_sha = client:script_load(lua_script) -print("✓ Lua script loaded: " .. script_sha) - --- Execute the script multiple times -for i = 1, 5 do - local result = client:evalsha(script_sha, 1, "test:counter", i) - print(" Execution " .. i .. ": counter = " .. result[1] .. " (was " .. result[2] .. ")") - - sentry.add_breadcrumb({ - message = "Script execution " .. i .. " completed", - category = "redis", - level = "debug", - data = { - counter_value = result[1], - increment = i - } - }) -end - --- Check the log entries -local logs = client:lrange("test:counter:log", 0, -1) -print("✓ Script execution log:") -for i, log_entry in ipairs(logs) do - print(" " .. i .. ". " .. log_entry) -end - --- Test 3: Error handling in Redis Lua script -print("\n--- Test 3: Error Handling in Lua Scripts ---") -local error_script = [[ - local key = KEYS[1] - if key == "error" then - error("Intentional error in Lua script") - end - return "success" -]] - -local error_script_sha = client:script_load(error_script) - --- Test successful execution -local success_result = client:evalsha(error_script_sha, 1, "success") -print("✓ Success case: " .. success_result) - --- Test error case with Sentry error capture -local success, err = pcall(function() - return client:evalsha(error_script_sha, 1, "error") -end) - -if not success then - print("✓ Caught Redis Lua script error: " .. err) - local exception_id = sentry.capture_exception({ - type = "RedisLuaScriptError", - message = "Redis Lua script execution failed: " .. err - }) - print("✓ Exception sent to Sentry: " .. exception_id) -end - --- Test 4: Complex Redis operations with transaction -print("\n--- Test 4: Complex Operations with Transaction ---") -sentry.add_breadcrumb({ - message = "Starting Redis transaction test", - category = "redis", - level = "info" -}) - --- Multi-operation Lua script (atomic transaction) -local transaction_script = [[ - local user_key = KEYS[1] - local session_key = KEYS[2] - local user_data = ARGV[1] - local session_id = ARGV[2] - - -- Set user data - redis.call('HSET', user_key, 'data', user_data, 'last_seen', ARGV[3]) - - -- Create session - redis.call('SET', session_key, session_id) - redis.call('EXPIRE', session_key, 3600) -- 1 hour - - -- Update activity counter - local activity_key = 'activity:' .. ARGV[4] - local count = redis.call('INCR', activity_key) - - return { - redis.call('HGETALL', user_key), - redis.call('GET', session_key), - count - } -]] - -local tx_script_sha = client:script_load(transaction_script) -local timestamp = os.time() -local result = client:evalsha(tx_script_sha, 2, - "user:123", "session:abc", - "test_user_data", "session_abc_123", timestamp, "daily") - -print("✓ Transaction completed:") -print(" User data: " .. table.concat(result[1], ", ")) -print(" Session: " .. result[2]) -print(" Activity count: " .. result[3]) - --- Clean up -client:del("test:key", "test:counter", "test:counter:log", "user:123", "session:abc", "activity:daily") -print("✓ Cleanup completed") - --- Final Sentry message -local final_event_id = sentry.capture_message("Redis Lua script integration test completed successfully!", "info") -print("\n✓ Final test report sent to Sentry: " .. final_event_id) - --- Test general Sentry error handling -local success, err = pcall(function() - error("Test error for stack trace verification") -end) - -if not success then - local exception_id = sentry.capture_exception({ - type = "TestError", - message = err, - extra = { - test_phase = "final_error_test", - redis_connected = true - } - }) - print("✓ Test exception captured: " .. exception_id) -end - -print("\n=== All Redis + Sentry integration tests completed successfully! ===") - --- Close Redis connection -client:quit() \ No newline at end of file diff --git a/example-event.png b/example-event.png deleted file mode 100644 index 440c493c4a498c53099e46ce22f2f5c5b509d14c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 215158 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

SgPC22eL({;AvQC9UK;OSg`$ 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 z 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%0Ay^Q_+;~AzolbAfY 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 bRfs3AOof8&7KALe059t^b{6Gk7vuj2_XSxY diff --git a/examples/logging.lua b/examples/logging.lua deleted file mode 100644 index 4ce30a1..0000000 --- a/examples/logging.lua +++ /dev/null @@ -1,347 +0,0 @@ -#!/usr/bin/env lua - --- Example demonstrating Sentry logging functionality --- This shows structured logging, print hooks, and log batching - --- Add build path for modules -package.path = "build/?.lua;build/?/init.lua;" .. package.path - -local sentry = require("sentry") -local logger = require("sentry.logger") -local performance = require("sentry.performance") - --- Initialize Sentry with logging enabled -sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - environment = "development", - release = "logging-example@1.0.0", - debug = true, - - -- Experimental logging configuration - _experiments = { - enable_logs = true, - max_buffer_size = 5, -- Small buffer for demo - flush_timeout = 3.0, -- Quick flush for demo - hook_print = true -- Hook print statements - } -}) - --- Initialize logger with configuration -logger.init({ - enable_logs = true, - max_buffer_size = 5, - flush_timeout = 3.0, - hook_print = true, - before_send_log = function(log_record) - -- Example log filtering/modification - if log_record.level == "debug" and log_record.body:find("sensitive") then - print("[Logger] Filtering sensitive debug log") - return nil -- Filter out sensitive logs - end - return log_record - end -}) - -print("=== Sentry Logging Example ===") -print("This example demonstrates various logging features\n") - --- Basic logging at different levels -print("1. Basic logging at different levels:") -logger.trace("This is a trace message for debugging") -logger.debug("User authentication process started") -logger.info("Application started successfully") -logger.warn("High memory usage detected: 85%") -logger.error("Failed to connect to external API") -logger.fatal("Critical system failure detected") - --- Structured logging with parameters -print("\n2. Structured logging with parameters:") -local user_id = "user_12345" -local action = "purchase" -local amount = 99.99 - -logger.info("User %s performed action %s with amount $%s", {user_id, action, amount}) -logger.warn("API endpoint %s response time %sms exceeds threshold", {"/api/users", 1500}) -logger.error("Database query failed for user %s with error %s", {user_id, "CONNECTION_TIMEOUT"}) - --- Logging with additional attributes -print("\n3. Logging with additional attributes:") -logger.info("Order processed successfully", nil, { - order_id = "order_789", - customer_type = "premium", - processing_time = 120, - payment_method = "credit_card", - is_express_shipping = true -}) - -logger.error("Payment processing failed", nil, { - error_code = "CARD_DECLINED", - attempts = 3, - amount = 149.99, - merchant_id = "merchant_456" -}) - --- Demonstrate print hook functionality -print("\n4. Print statements (automatically captured as logs):") -print("This is a regular print statement that will be logged") -print("Debug info:", "status=active", "count=42") -print("Multiple", "arguments", "in", "print", "statement") - --- Logging within transactions (trace correlation) -print("\n5. Logging within transaction context:") -local transaction = performance.start_transaction("logging_demo", "example") - -logger.info("Starting business process within transaction") - -local span = transaction:start_span("data_processing", "Process user data") -logger.debug("Processing user data for user %s", {user_id}) -logger.info("Data validation completed successfully") -span:finish("ok") - -local payment_span = transaction:start_span("payment", "Process payment") -logger.warn("Payment provider response time high: %sms", {2100}) -logger.error("Payment failed with error: %s", {"INSUFFICIENT_FUNDS"}, { - retry_count = 2, - fallback_available = true -}) -payment_span:finish("error") - -transaction:finish("error") - --- Demonstrate sensitive log filtering -print("\n6. Log filtering (sensitive logs are filtered out):") -logger.debug("Regular debug message") -logger.debug("This contains sensitive information and will be filtered") - --- Force flush before ending -print("\n7. Forcing log buffer flush:") -print("Current buffer status:", logger.get_buffer_status().logs, "logs pending") -logger.flush() - --- Demonstrate exception capture with log correlation -print("\n8. Exception capture with log correlation:") -logger.error("Critical error occurred in payment processing") -logger.info("Attempting to capture exception with context") - --- Create a realistic nested function scenario similar to basic example -local function validate_card_number(card_number, cvv_code) - logger.debug("Validating card number ending in %s", {string.sub(card_number, -4)}) - - -- Simulate card validation - if #card_number ~= 16 then - error("CardValidationError: Invalid card number length") - end - - if cvv_code < 100 or cvv_code > 999 then - error("CardValidationError: Invalid CVV code") - end - - logger.info("Card validation successful") - return true -end - -local function check_customer_limits(customer_id, amount, customer_tier) - logger.debug("Checking limits for customer %s (tier: %s)", {customer_id, customer_tier}) - - local daily_limits = { - bronze = 500, - silver = 2000, - gold = 10000, - platinum = 50000 - } - - local limit = daily_limits[customer_tier] or 100 - logger.info("Customer %s has daily limit of $%s", {customer_id, limit}) - - if amount > limit then - error("LimitExceededError: Transaction amount $" .. tostring(amount) .. " exceeds daily limit of $" .. tostring(limit)) - end - - return true -end - -local function validate_payment(customer_id, amount, payment_method, card_info) - logger.debug("Validating payment for customer %s", {customer_id}) - - if not customer_id or customer_id == "" then - error("ValidationError: Invalid customer ID provided") - end - - if amount <= 0 then - error("ValidationError: Invalid payment amount: $" .. tostring(amount)) - end - - -- Check customer limits first - check_customer_limits(customer_id, amount, card_info.tier or "bronze") - - -- Validate card if it's a credit card payment - if payment_method == "credit_card" then - validate_card_number(card_info.number, card_info.cvv) - - if amount > 5000 then - logger.warn("High value credit card transaction: $%s for customer %s", {amount, customer_id}) - end - end - - logger.info("Payment validation completed for customer %s", {customer_id}) - return true -end - -local function connect_to_gateway(gateway_config, retry_count) - logger.debug("Connecting to payment gateway (attempt %s)", {retry_count}) - - -- Simulate connection logic - if retry_count > 3 then - error("ConnectionError: Maximum retry attempts exceeded") - end - - logger.info("Successfully connected to payment gateway") - return {connection_id = "conn_" .. math.random(10000, 99999)} -end - -local function process_payment_request(gateway_connection, transaction_data, timeout_seconds) - logger.info("Processing payment request with timeout %s seconds", {timeout_seconds}) - - -- Simulate the actual payment processing error - if transaction_data.amount > 1000 then - logger.error("Payment gateway timeout after %s seconds", {timeout_seconds}) - error("PaymentGatewayTimeoutError: Connection failed after " .. tostring(timeout_seconds) .. " seconds") - end - - return { - status = "approved", - reference = "ref_" .. math.random(100000, 999999), - auth_code = "auth_" .. math.random(1000, 9999) - } -end - -local function process_transaction(order_data, gateway_config) - logger.info("Starting transaction processing for order %s", {order_data.order_id}) - - -- Add some structured data - logger.info("Order details", nil, { - order_id = order_data.order_id, - customer_id = order_data.customer_id, - amount = order_data.amount, - payment_method = order_data.payment_method, - merchant_id = "merchant_789" - }) - - -- Validate the payment first - validate_payment(order_data.customer_id, order_data.amount, order_data.payment_method, order_data.card_info) - - -- Connect to payment gateway - local connection = connect_to_gateway(gateway_config, 1) - logger.info("Using gateway connection %s", {connection.connection_id}) - - -- Process the actual payment - local payment_result = process_payment_request(connection, order_data, 30) - - logger.info("Transaction processed successfully with reference %s", {payment_result.reference}) - return { - status = "success", - transaction_id = "txn_" .. order_data.order_id, - payment_reference = payment_result.reference - } -end - -local function handle_customer_order(customer_id, items, payment_info, gateway_config) - logger.info("Processing customer order for %s with %s items", {customer_id, #items}) - - local order_data = { - order_id = "order_" .. math.random(1000, 9999), - customer_id = customer_id, - amount = payment_info.amount, - payment_method = payment_info.method, - items = items, - card_info = payment_info.card_info - } - - logger.debug("Created order data structure", nil, { - order_id = order_data.order_id, - item_count = #items, - total_amount = payment_info.amount - }) - - return process_transaction(order_data, gateway_config) -end - --- Simulate a realistic order scenario that will trigger the exception -local customer_id = "customer_12345" -local items = {"laptop", "mouse", "keyboard"} -local payment_info = { - amount = 1250, -- This will trigger the timeout error - method = "credit_card", - card_info = { - number = "4532123456789012", - cvv = 123, - tier = "gold" - } -} - -local gateway_config = { - url = "https://payment-gateway.example.com", - merchant_id = "merchant_789", - timeout = 30 -} - --- Capture exception with surrounding log context -logger.info("Starting customer order processing workflow") - --- Create a function chain that will trigger the error deeper in the stack -local function process_order_workflow(customer_id, items, payment_info, gateway_config) - logger.info("Initializing order workflow") - return handle_customer_order(customer_id, items, payment_info, gateway_config) -end - -local function execute_customer_transaction(customer_data, order_details, gateway_settings) - logger.debug("Executing customer transaction with gateway settings") - return process_order_workflow(customer_data.id, order_details.items, order_details.payment, gateway_settings) -end - -local function start_business_process(customer_info, order_info, payment_config) - logger.info("Starting business process for customer %s", {customer_info.id}) - return execute_customer_transaction(customer_info, order_info, payment_config) -end - --- Use the nested function chain to create multi-frame stack trace -local customer_data = {id = customer_id, tier = "gold"} -local order_details = {items = items, payment = payment_info} - --- Use xpcall with a custom error handler that captures the original stack trace -local function error_handler(err) - logger.error("Order processing failed with error: %s", {tostring(err)}) - logger.error("Failed order details", nil, { - customer_id = customer_id, - item_count = #items, - payment_amount = payment_info.amount, - error_location = "payment_processing" - }) - - -- Capture the exception, this will use the current stack trace from where the error occurred - sentry.capture_exception({ - type = "PaymentGatewayTimeoutError", - message = tostring(err) - }) - logger.info("Exception captured and sent to Sentry with full context") - return err -end - -local success, result = xpcall(function() - return start_business_process(customer_data, order_details, gateway_config) -end, error_handler) - -if success then - logger.info("Order completed successfully: %s", {result.transaction_id}) -end - --- Wait a moment for async operations -print("\nWaiting for logs and exceptions to be sent...") -os.execute("sleep 3") - -print("\nExample completed! Check your Sentry project for:") -print("- Structured logs with parameters and attributes") -print("- Print statement captures") -print("- Exception with correlated log messages") -print("- Correlation with transaction traces") -print("- Different log levels and severity") \ No newline at end of file diff --git a/examples/logging_simple.lua b/examples/logging_simple.lua deleted file mode 100644 index 2756e4b..0000000 --- a/examples/logging_simple.lua +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env lua - --- Simple logging example that demonstrates functionality without network issues --- This shows the logging API works correctly even when transport fails - --- Add build path for modules -package.path = "build/?.lua;build/?/init.lua;" .. package.path - -local sentry = require("sentry") -local logger = require("sentry.logger") - -print("=== Simple Sentry Logging Demo ===\n") - --- Initialize Sentry -sentry.init({ - dsn = "https://6e6b321e9b334de79f0d56c54a0e2d94@o4505842095628288.ingest.us.sentry.io/4508485766955008", - debug = true -}) - --- Initialize logger with small buffer for immediate demonstration -logger.init({ - enable_logs = true, - max_buffer_size = 3, -- Very small buffer to trigger frequent flushes - flush_timeout = 1.0, -- Quick timeout - hook_print = false -- Disable print hooking for cleaner output -}) - -print("1. Testing basic logging levels:") -logger.info("Application started successfully") -logger.warn("This is a warning message") -logger.error("This is an error message") - --- Should auto-flush here due to buffer size (3) -print(" -> Buffer should have flushed automatically\n") - -print("2. Testing structured logging:") -logger.info("User %s logged in from %s", {"john_doe", "192.168.1.100"}) -logger.error("Database query failed: %s", {"Connection timeout"}) - -print(" -> Another flush should happen\n") - -print("3. Testing with attributes:") -logger.info("Order processed", nil, { - order_id = "ORD-12345", - amount = 99.99, - currency = "USD", - success = true -}) - -print(" -> One more flush\n") - -print("4. Buffer status check:") -local status = logger.get_buffer_status() -print(" Current buffer:", status.logs, "logs pending") -print(" Max buffer size:", status.max_size) - -print("\n5. Manual flush:") -logger.flush() -print(" -> Manually flushed remaining logs") - -print("\n=== Demo Complete ===") -print("The logging system is working correctly!") -print("Network errors are expected since logging is experimental in Sentry.") -print("The important thing is that logs are being:") -print("- ✅ Properly formatted") -print("- ✅ Batched correctly") -print("- ✅ Sent to transport layer") -print("- ✅ Structured with parameters and attributes") \ No newline at end of file diff --git a/examples/love2d/test_modules.lua b/examples/love2d/test_modules.lua deleted file mode 100644 index 7996315..0000000 --- a/examples/love2d/test_modules.lua +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env lua - --- Test script to verify Love2D example modules can be loaded - --- Add build path for modules -package.path = "../../build/?.lua;../../build/?/init.lua;" .. package.path - -print("Testing Love2D example module loading...") - --- Test basic Sentry module loading -local success, sentry = pcall(require, "sentry") -if success then - print("✓ Sentry module loaded successfully") -else - print("✗ Failed to load Sentry module:", sentry) - os.exit(1) -end - --- Test logger module -local success, logger = pcall(require, "sentry.logger") -if success then - print("✓ Logger module loaded successfully") -else - print("✗ Failed to load logger module:", logger) - os.exit(1) -end - --- Test Love2D platform modules -local success, transport = pcall(require, "sentry.platforms.love2d.transport") -if success then - print("✓ Love2D transport module loaded successfully") -else - print("✗ Failed to load Love2D transport module:", transport) - os.exit(1) -end - -local success, os_detection = pcall(require, "sentry.platforms.love2d.os_detection") -if success then - print("✓ Love2D OS detection module loaded successfully") -else - print("✗ Failed to load Love2D OS detection module:", os_detection) - os.exit(1) -end - -local success, context = pcall(require, "sentry.platforms.love2d.context") -if success then - print("✓ Love2D context module loaded successfully") -else - print("✗ Failed to load Love2D context module:", context) - os.exit(1) -end - -print("\nAll Love2D example modules loaded successfully!") -print("The Love2D example should work when run with 'love examples/love2d'") - --- Test that Love2D transport is available (without _G.love it should return false) -print("\nTesting Love2D transport availability (should be false without Love2D runtime):") -print("Love2D transport available:", transport.is_love2d_available()) - -print("\nModule loading test completed successfully!") \ No newline at end of file diff --git a/examples/nginx.lua b/examples/nginx.lua deleted file mode 100644 index fce4f85..0000000 --- a/examples/nginx.lua +++ /dev/null @@ -1,53 +0,0 @@ -local sentry = require("sentry.init") - --- Initialize Sentry for nginx/OpenResty environment -sentry.init({ - dsn = "https://your-dsn@sentry.io/project-id", - environment = "nginx", - debug = true -}) - --- Set nginx-specific context -sentry.set_tag("platform", "nginx") -sentry.set_tag("server", ngx.var.server_name or "localhost") - -sentry.set_extra("request_method", ngx.var.request_method) -sentry.set_extra("request_uri", ngx.var.request_uri) -sentry.set_extra("remote_addr", ngx.var.remote_addr) - --- Add request breadcrumb -sentry.add_breadcrumb({ - message = "HTTP request received", - category = "http", - level = "info", - data = { - method = ngx.var.request_method, - uri = ngx.var.request_uri, - user_agent = ngx.var.http_user_agent - } -}) - --- Example: Capture successful request -sentry.capture_message("Request processed successfully", "info") - --- Example: Error handling in nginx -local function process_request() - -- Simulate request processing - if not ngx.var.arg_user_id then - error("Missing required parameter: user_id") - end - - -- Process request... - ngx.say("Hello, user " .. ngx.var.arg_user_id) -end - -local success, err = pcall(process_request) -if not success then - sentry.capture_exception({ - type = "RequestError", - message = err - }) - - ngx.status = 400 - ngx.say("Bad Request") -end \ No newline at end of file diff --git a/examples/redis.lua b/examples/redis.lua deleted file mode 100644 index 06a67c6..0000000 --- a/examples/redis.lua +++ /dev/null @@ -1,39 +0,0 @@ -local sentry = require("sentry.init") -local redis_integration = require("sentry.integrations.redis") - --- Initialize Sentry with Redis transport -local RedisTransport = redis_integration.setup_redis_integration() - -sentry.init({ - dsn = "https://your-dsn@sentry.io/project-id", - environment = "redis", - transport = RedisTransport, - redis_key = "sentry:events" -}) - --- Redis-specific context -sentry.set_tag("platform", "redis") -sentry.set_extra("redis_version", "7.0") - --- Simulate Redis script execution -sentry.add_breadcrumb({ - message = "Redis script started", - category = "redis", - level = "info" -}) - --- Capture events that will be queued in Redis -sentry.capture_message("Redis script executed successfully", "info") - --- Example of error in Redis context -local success, err = pcall(function() - -- Simulate Redis operation error - redis.call("INVALID_COMMAND") -end) - -if not success then - sentry.capture_exception({ - type = "RedisError", - message = err - }) -end \ No newline at end of file diff --git a/examples/roblox/README.md b/examples/roblox/README.md index 0323341..5d03adc 100644 --- a/examples/roblox/README.md +++ b/examples/roblox/README.md @@ -6,7 +6,7 @@ Example Sentry integration for Roblox games. **Use the all-in-one file:** -1. **Copy** `sentry-all-in-one.lua` +1. **Copy** `sentry-all-in-one.luau` 2. **Paste** into ServerScriptService as a Script 3. **Update DSN** on line 18 4. **Enable HTTP**: Game Settings → Security → "Allow HTTP Requests" @@ -14,9 +14,7 @@ Example Sentry integration for Roblox games. ## 📁 Available Files -- **`sentry-all-in-one.lua`** ⭐ **Complete single-file solution** -- **`sentry-roblox-sdk.lua`** - Reusable SDK module -- **`clean-example.lua`** - Example using the SDK module +- **`sentry-all-in-one.luau`** ⭐ **Complete single-file solution** ## 🧪 Testing @@ -85,20 +83,8 @@ sentry.add_breadcrumb({ **"attempt to index nil with 'capture_message'"** → Make sure sentry.init() was called successfully first -## 🔨 Validation - -To validate the Roblox integration is ready: - -```bash -make roblox-all-in-one -# or directly: -./scripts/generate-roblox-all-in-one.sh -``` - -This checks that the `sentry-all-in-one.lua` file contains all required components and uses the standard SDK API. - ## 🎉 Ready to Go! -Use `sentry-all-in-one.lua` to get started immediately. Copy, paste, update DSN, and test! +Use `sentry-all-in-one.luau` to get started immediately. Copy, paste, update DSN, and test! **Happy debugging with Sentry! 🐛→✅** \ No newline at end of file diff --git a/examples/roblox/sentry-all-in-one.lua b/examples/roblox/sentry-all-in-one.luau similarity index 100% rename from examples/roblox/sentry-all-in-one.lua rename to examples/roblox/sentry-all-in-one.luau diff --git a/examples/tracing_basic.lua b/examples/tracing_basic.lua deleted file mode 100644 index fa678aa..0000000 --- a/examples/tracing_basic.lua +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env lua --- Basic distributed tracing example --- Shows core tracing concepts: transactions, spans, and event correlation - -package.path = "build/?.lua;build/?/init.lua;;" .. package.path - -local sentry = require("sentry") -local performance = require("sentry.performance") - --- Initialize Sentry with correct DSN -sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - debug = true, - environment = "tracing-basic-example" -}) - -print("🎯 Basic Distributed Tracing Example") -print("====================================") - --- Example 1: Simple transaction -print("\n📦 Example 1: Simple Transaction") -local tx1 = performance.start_transaction("user_registration", "http.server") -print("✅ Started transaction:", tx1.transaction) - --- Simulate validation -local validation_span = performance.start_span("validation.input", "Validate email and password") -print(" → Validating user input...") -os.execute("sleep 0.05") -performance.finish_span("ok") - --- Simulate database operation -local db_span = performance.start_span("db.query", "INSERT INTO users") -print(" → Creating user record...") -os.execute("sleep 0.1") -performance.finish_span("ok") - --- Capture a success event within the transaction -sentry.capture_message("User registered successfully", "info") - -performance.finish_transaction("ok") -print("✅ Transaction completed") - --- Example 2: Transaction with error -print("\n❌ Example 2: Transaction with Error") -local tx2 = performance.start_transaction("payment_processing", "task") -print("✅ Started transaction:", tx2.transaction) - --- Simulate payment validation -local validate_span = performance.start_span("payment.validate", "Validate credit card") -os.execute("sleep 0.03") -performance.finish_span("ok") - --- Simulate payment failure -local charge_span = performance.start_span("payment.charge", "Charge credit card") -os.execute("sleep 0.08") - --- Capture error within the transaction -sentry.capture_exception({ - type = "PaymentError", - message = "Card declined: insufficient funds" -}, "error") - -performance.finish_span("internal_error") -performance.finish_transaction("internal_error") -print("✅ Transaction completed (with error)") - --- Example 3: Complex nested operations -print("\n🔢 Example 3: Complex Data Pipeline") -local tx3 = performance.start_transaction("data_processing", "task") -print("✅ Started transaction:", tx3.transaction) - --- Stage 1: Data extraction -local extract_span = performance.start_span("extract.data", "Extract from external API") -print(" → Extracting data...") - --- Nested HTTP call within extraction -local api_span = performance.start_span("http.client", "GET /api/users") -os.execute("sleep 0.04") -performance.finish_span("ok") -print(" → API call completed") - -performance.finish_span("ok") -print(" → Extraction completed") - --- Stage 2: Data transformation -local transform_span = performance.start_span("transform.data", "Clean and normalize data") -print(" → Transforming data...") -os.execute("sleep 0.06") -performance.finish_span("ok") - --- Stage 3: Data loading -local load_span = performance.start_span("load.data", "Load into data warehouse") -print(" → Loading data...") -os.execute("sleep 0.05") -performance.finish_span("ok") - --- Add breadcrumb and final message -sentry.add_breadcrumb({ - message = "Data pipeline completed", - category = "processing", - level = "info", - data = { records_processed = 1250 } -}) - -sentry.capture_message("Data pipeline completed successfully", "info") - -performance.finish_transaction("ok") -print("✅ Transaction completed") - -print("\n🎉 Basic tracing examples completed!") -print("\nCheck your Sentry dashboard to see:") -print("• 3 transactions with different operations") -print("• Nested spans showing timing and hierarchy") -print("• Events correlated within transactions") -print("• Error handling within transaction context") \ No newline at end of file diff --git a/examples/tracing_client.lua b/examples/tracing_client.lua deleted file mode 100644 index 3d780ea..0000000 --- a/examples/tracing_client.lua +++ /dev/null @@ -1,218 +0,0 @@ -#!/usr/bin/env lua --- Distributed Tracing HTTP Client --- Requires: luarocks install luasocket --- Usage: First start tracing_server.lua, then run lua examples/tracing_client.lua --- This demonstrates REAL distributed tracing between processes via HTTP - -package.path = "build/?.lua;build/?/init.lua;;" .. package.path - --- Require luasocket - fail if not available -local http = require("socket.http") -local ltn12 = require("ltn12") -local sentry = require("sentry") -local tracing = require("sentry.tracing") -local performance = require("sentry.performance") - --- Initialize Sentry -sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - debug = true, - environment = "distributed-tracing-client" -}) - --- Initialize tracing -tracing.init({ - trace_propagation_targets = {"*"} -- Allow all for demo -}) - -print("🚀 Distributed Tracing Client") -print("=============================") -print("Making HTTP requests to server at http://localhost:8080") -print("Each request will propagate trace context and create distributed spans\n") - --- Helper function to make HTTP request with trace propagation -local function make_request(method, url, body, transaction) - local headers = {} - - -- Start HTTP client span FIRST - local span = transaction:start_span("http.client", method .. " " .. url) - - -- THEN get trace headers for propagation (now from the current span context) - local trace_headers = tracing.get_request_headers(url) - if trace_headers then - headers["sentry-trace"] = trace_headers["sentry-trace"] - headers["baggage"] = trace_headers["baggage"] - headers["traceparent"] = trace_headers["traceparent"] - print(" → Propagating trace headers:") - if trace_headers["sentry-trace"] then - print(" sentry-trace:", trace_headers["sentry-trace"]) - end - else - print(" → No trace headers available to propagate") - end - - -- Set content headers for POST requests - if method == "POST" and body then - headers["Content-Type"] = "application/json" - headers["Content-Length"] = tostring(#body) - end - - local response_body = {} - local result, status, response_headers - - if method == "GET" then - result, status, response_headers = http.request{ - url = url, - method = method, - headers = headers, - sink = ltn12.sink.table(response_body) - } - elseif method == "POST" then - result, status, response_headers = http.request{ - url = url, - method = method, - headers = headers, - source = ltn12.source.string(body), - sink = ltn12.sink.table(response_body) - } - end - - local response_text = table.concat(response_body) - print(" → HTTP", status, "(" .. #response_text .. " bytes)") - - -- Finish span with status - local span_status = "http_error" - if type(status) == "number" and status >= 200 and status < 300 then - span_status = "ok" - end - span:finish(span_status) - - return result, status, response_text -end - --- Demo 1: Health Check -print("📍 Demo 1: Health Check Request") --- Note: start_transaction will automatically create a new trace if none exists -local tx1 = performance.start_transaction("client_health_check", "http.client") - -local result1, status1, body1 = make_request("GET", "http://localhost:8080/", nil, tx1) -if result1 then - print(" ✅ Health check successful") - sentry.capture_message("Health check completed from client", "info") -else - print(" ❌ Health check failed:", status1) - sentry.capture_message("Health check failed from client: " .. tostring(status1), "error") -end - -tx1:finish("ok") -print() - --- Demo 2: User List Request -print("📍 Demo 2: Fetch Users") --- Start fresh trace for this demo -tracing.start_trace() -local tx2 = performance.start_transaction("client_fetch_users", "http.client") - -local result2, status2, body2 = make_request("GET", "http://localhost:8080/api/users", nil, tx2) -if result2 and status2 == 200 then - -- Parse response to show user count - local json = require("sentry.utils.json") - local success, data = pcall(json.decode, body2) - if success and data and data.users then - print(" ✅ Retrieved", #data.users, "users") - sentry.capture_message("Retrieved " .. #data.users .. " users successfully", "info") - else - print(" ✅ Users request successful") - sentry.capture_message("Users request completed", "info") - end -else - print(" ❌ Users request failed:", status2) - sentry.capture_message("Users request failed: " .. tostring(status2), "error") -end - -tx2:finish("ok") -print() - --- Demo 3: Create Order (Complex Workflow) -print("📍 Demo 3: Create Order (Complex Server Workflow)") --- Start fresh trace for this demo -tracing.start_trace() -local tx3 = performance.start_transaction("client_create_order", "http.client") - --- Simulate order data preparation -local prep_span = tx3:start_span("order.prepare", "Prepare order data") -print(" → Preparing order data...") -local order_data = { - product_id = "PROD-123", - quantity = 2, - customer_id = "CUST-456" -} -local json_body = require("sentry.utils.json").encode(order_data) -os.execute("sleep 0.02") -- Simulate prep time -prep_span:finish("ok") - -local result3, status3, body3 = make_request("POST", "http://localhost:8080/api/orders", json_body, tx3) -if result3 and status3 == 201 then - print(" ✅ Order created successfully") - sentry.capture_message("Order creation completed", "info") -else - print(" ❌ Order creation failed:", status3) - sentry.capture_message("Order creation failed: " .. tostring(status3), "error") -end - -tx3:finish("ok") -print() - --- Demo 4: Slow Request -print("📍 Demo 4: Slow Request (Performance Monitoring)") --- Start fresh trace for this demo -tracing.start_trace() -local tx4 = performance.start_transaction("client_slow_request", "http.client") - -print(" → Making slow request (will take ~800ms on server)...") -local result4, status4, body4 = make_request("GET", "http://localhost:8080/api/slow", nil, tx4) -if result4 then - print(" ✅ Slow request completed") - sentry.capture_message("Slow request completed successfully", "info") -else - print(" ❌ Slow request failed:", status4) - sentry.capture_message("Slow request failed: " .. tostring(status4), "error") -end - -tx4:finish("ok") -print() - --- Demo 5: Error Request (Error Propagation) -print("📍 Demo 5: Error Request (Distributed Error Tracing)") --- Start fresh trace for this demo -tracing.start_trace() -local tx5 = performance.start_transaction("client_error_request", "http.client") - -local result5, status5, body5 = make_request("GET", "http://localhost:8080/api/error", nil, tx5) -if result5 and status5 == 500 then - print(" ✅ Error request completed (expected 500)") - sentry.capture_message("Error endpoint tested - server error handled correctly", "info") -else - print(" ❌ Unexpected response:", status5) - sentry.capture_message("Error endpoint unexpected response: " .. tostring(status5), "warning") -end - -tx5:finish("ok") -print() - -print("🎉 Distributed tracing client demo completed!") -print() -print("Check your Sentry dashboard to see:") -print("• Client-side transactions showing HTTP requests") -print("• Server-side transactions showing request processing") -print("• Distributed traces connecting client and server spans") -print("• Error correlation across both processes") -print("• Performance data for the complete request flow") -print() -print("The traces should show:") -print(" Client Transaction") -print(" ├── http.client span (request to server)") -print(" └── Server Transaction (same trace)") -print(" ├── db.query spans") -print(" ├── cache.get spans") -print(" └── validation spans") \ No newline at end of file diff --git a/examples/tracing_server.lua b/examples/tracing_server.lua deleted file mode 100644 index 7b0ca66..0000000 --- a/examples/tracing_server.lua +++ /dev/null @@ -1,280 +0,0 @@ -#!/usr/bin/env lua --- Distributed Tracing HTTP Server --- Requires: luarocks install pegasus --- Usage: lua examples/tracing_server.lua --- Then run tracing_client.lua in another terminal - -package.path = "build/?.lua;build/?/init.lua;;" .. package.path - --- Require pegasus - fail if not available -local pegasus = require("pegasus") -local sentry = require("sentry") -local tracing = require("sentry.tracing") -local performance = require("sentry.performance") - --- Initialize Sentry -sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - debug = true, - environment = "distributed-tracing-server" -}) - --- Initialize tracing -tracing.init({ - trace_propagation_targets = {"*"} -- Allow all for demo -}) - -print("🚀 Distributed Tracing Server") -print("=============================") -print("Server starting on http://localhost:8080") -print("Endpoints:") -print(" GET / - Simple health check") -print(" GET /api/users - User list with database simulation") -print(" POST /api/orders - Order processing with complex workflow") -print(" GET /api/slow - Slow endpoint demonstrating timing") -print(" GET /api/error - Error endpoint for error tracing") -print("\nRun tracing_client.lua in another terminal to test distributed tracing") -print("Press Ctrl+C to stop\n") - --- Create server -local server = pegasus:new({ - host = "0.0.0.0", - port = 8080 -}) - --- Helper to extract trace headers -local function extract_headers(request) - local headers = {} - if request.headers then - local req_headers = request:headers() - headers["sentry-trace"] = req_headers["sentry-trace"] - headers["baggage"] = req_headers["baggage"] - headers["traceparent"] = req_headers["traceparent"] - end - return headers -end - --- Helper to handle GET / -local function handle_health_check(request, response) - local incoming_headers = extract_headers(request) - local context = tracing.continue_trace_from_request(incoming_headers) - - -- Parse incoming trace header to get correct parent span ID - local headers = require("sentry.tracing.headers") - local incoming_trace = headers.parse_sentry_trace(incoming_headers["sentry-trace"]) - - -- Start transaction with correct parent-child relationship - local tx = performance.start_transaction("GET /", "http.server", { - trace_id = context and context.trace_id, - parent_span_id = incoming_trace and incoming_trace.span_id, -- Use incoming span as parent - span_id = headers.generate_span_id() - }) - - local health = { status = "healthy", timestamp = os.time() } - - sentry.capture_message("Health check completed", "info") - tx:finish("ok") - - response:statusCode(200) - response:addHeader("Content-Type", "application/json") - response:write(require("sentry.utils.json").encode(health)) -end - --- Helper to handle GET /api/users -local function handle_get_users(request, response) - local incoming_headers = extract_headers(request) - local context = tracing.continue_trace_from_request(incoming_headers) - - -- Parse incoming trace header to get correct parent span ID - local headers = require("sentry.tracing.headers") - local incoming_trace = headers.parse_sentry_trace(incoming_headers["sentry-trace"]) - - local tx = performance.start_transaction("GET /api/users", "http.server", { - trace_id = context and context.trace_id, - parent_span_id = incoming_trace and incoming_trace.span_id, -- Use incoming span as parent - span_id = headers.generate_span_id() - }) - print("📍 Handling GET /api/users") - - local db_span = tx:start_span("db.query", "SELECT * FROM users WHERE active = 1") - print(" → Querying database...") - os.execute("sleep 0.1") - db_span:finish("ok") - - local cache_span = tx:start_span("cache.get", "Redis GET users_list") - print(" → Checking cache...") - os.execute("sleep 0.05") - cache_span:finish("ok") - - local users = { - { id = 1, name = "Alice", email = "alice@example.com" }, - { id = 2, name = "Bob", email = "bob@example.com" }, - { id = 3, name = "Carol", email = "carol@example.com" } - } - - sentry.capture_message("Users retrieved successfully", "info") - tx:finish("ok") - - response:statusCode(200) - response:addHeader("Content-Type", "application/json") - response:write(require("sentry.utils.json").encode({ users = users, count = #users })) -end - --- Helper to handle POST /api/orders -local function handle_create_order(request, response) - local incoming_headers = extract_headers(request) - local context = tracing.continue_trace_from_request(incoming_headers) - - -- Parse incoming trace header to get correct parent span ID - local headers = require("sentry.tracing.headers") - local incoming_trace = headers.parse_sentry_trace(incoming_headers["sentry-trace"]) - - local tx = performance.start_transaction("POST /api/orders", "http.server", { - trace_id = context and context.trace_id, - parent_span_id = incoming_trace and incoming_trace.span_id, -- Use incoming span as parent - span_id = headers.generate_span_id() - }) - print("📍 Handling POST /api/orders") - - local validation_span = tx:start_span("validation.order", "Validate order data") - print(" → Validating order...") - os.execute("sleep 0.03") - validation_span:finish("ok") - - local inventory_span = tx:start_span("inventory.check", "Check product availability") - print(" → Checking inventory...") - os.execute("sleep 0.08") - inventory_span:finish("ok") - - local payment_span = tx:start_span("payment.process", "Process payment") - print(" → Processing payment...") - os.execute("sleep 0.12") - payment_span:finish("ok") - - local create_span = tx:start_span("db.insert", "INSERT INTO orders") - print(" → Creating order record...") - os.execute("sleep 0.06") - create_span:finish("ok") - - local order = { - id = "ORD-" .. math.random(1000, 9999), - status = "confirmed", - total = 29.99 - } - - sentry.capture_message("Order created successfully: " .. order.id, "info") - tx:finish("ok") - - response:statusCode(201) - response:addHeader("Content-Type", "application/json") - response:write(require("sentry.utils.json").encode({ order = order, message = "Order created" })) -end - --- Helper to handle GET /api/slow -local function handle_slow_endpoint(request, response) - local incoming_headers = extract_headers(request) - local context = tracing.continue_trace_from_request(incoming_headers) - - -- Parse incoming trace header to get correct parent span ID - local headers = require("sentry.tracing.headers") - local incoming_trace = headers.parse_sentry_trace(incoming_headers["sentry-trace"]) - - local tx = performance.start_transaction("GET /api/slow", "http.server", { - trace_id = context and context.trace_id, - parent_span_id = incoming_trace and incoming_trace.span_id, -- Use incoming span as parent - span_id = headers.generate_span_id() - }) - print("📍 Handling GET /api/slow") - - local external_span = tx:start_span("http.client", "External API call") - print(" → Calling external API (slow)...") - os.execute("sleep 0.5") - external_span:finish("ok") - - local slow_db_span = tx:start_span("db.query", "Complex analytical query") - print(" → Running complex query...") - os.execute("sleep 0.3") - slow_db_span:finish("ok") - - sentry.capture_message("Slow endpoint completed", "info") - tx:finish("ok") - - response:statusCode(200) - response:addHeader("Content-Type", "application/json") - response:write(require("sentry.utils.json").encode({ - message = "Slow operation completed", - duration_ms = 800 - })) -end - --- Helper to handle GET /api/error -local function handle_error_endpoint(request, response) - local incoming_headers = extract_headers(request) - local context = tracing.continue_trace_from_request(incoming_headers) - - -- Parse incoming trace header to get correct parent span ID - local headers = require("sentry.tracing.headers") - local incoming_trace = headers.parse_sentry_trace(incoming_headers["sentry-trace"]) - - local tx = performance.start_transaction("GET /api/error", "http.server", { - trace_id = context and context.trace_id, - parent_span_id = incoming_trace and incoming_trace.span_id, -- Use incoming span as parent - span_id = headers.generate_span_id() - }) - print("📍 Handling GET /api/error") - - local work_span = tx:start_span("process.data", "Processing data") - print(" → Processing data...") - os.execute("sleep 0.05") - work_span:finish("ok") - - local error_span = tx:start_span("db.query", "Query user preferences") - print(" → Error occurred!") - - sentry.capture_exception({ - type = "DatabaseError", - message = "Connection timeout: Could not connect to database after 30s" - }, "error") - - error_span:finish("internal_error") - tx:finish("internal_error") - - response:statusCode(500) - response:addHeader("Content-Type", "application/json") - response:write(require("sentry.utils.json").encode({ - error = "Internal server error", - message = "Database connection failed" - })) -end - --- Start server with request handler -server:start(function(request, response) - local method = request:method() - local path = request:path() - - print("📨 Incoming:", method, path) - - -- Route handling - if method == "GET" and path == "/" then - handle_health_check(request, response) - elseif method == "GET" and path == "/api/users" then - handle_get_users(request, response) - elseif method == "POST" and path == "/api/orders" then - handle_create_order(request, response) - elseif method == "GET" and path == "/api/slow" then - handle_slow_endpoint(request, response) - elseif method == "GET" and path == "/api/error" then - handle_error_endpoint(request, response) - else - -- 404 handler - response:statusCode(404) - response:addHeader("Content-Type", "application/json") - response:write(require("sentry.utils.json").encode({ - error = "Not found", - path = path, - method = method - })) - end -end) - -print("✅ Server started on http://localhost:8080") \ No newline at end of file diff --git a/platforms/lua/http_client.lua b/platforms/lua/http_client.lua deleted file mode 100644 index 7677dac..0000000 --- a/platforms/lua/http_client.lua +++ /dev/null @@ -1,205 +0,0 @@ ----@class platforms.lua.http_client ---- HTTP client integrations for standard Lua with popular HTTP libraries ---- Provides automatic trace header injection for outgoing requests - -local http_client = {} - -local tracing = require("sentry.tracing") - ----LuaSocket HTTP client integration ----@class LuaSocketIntegration -local luasocket = {} - ----Wrap LuaSocket http.request function with tracing ----@param http_module table The LuaSocket http module ----@return table wrapped_module Wrapped http module with tracing -function luasocket.wrap_http_module(http_module) - if not http_module or not http_module.request then - error("Invalid LuaSocket http module - missing request function") - end - - local original_request = http_module.request - - -- Wrap the request function - http_module.request = function(url_or_options, body) - local url, options - - -- Handle both forms: request(url, body) and request(options_table) - if type(url_or_options) == "string" then - url = url_or_options - options = { url = url, source = body } - else - options = url_or_options or {} - url = options.url - end - - -- Add trace headers - if url and tracing.is_active() then - options.headers = options.headers or {} - local trace_headers = tracing.get_request_headers(url) - for key, value in pairs(trace_headers) do - options.headers[key] = value - end - end - - -- Call original request function - if type(url_or_options) == "string" then - return original_request(options, body) - else - return original_request(options) - end - end - - return http_module -end - ----lua-http client integration ----@class LuaHttpIntegration -local lua_http = {} - ----Wrap lua-http request object with tracing ----@param http_request table The lua-http request object ----@return table wrapped_request Wrapped request object with tracing -function lua_http.wrap_request(http_request) - if not http_request or not http_request.get_headers_as_sequence then - error("Invalid lua-http request object") - end - - -- Store original go method - local original_go = http_request.go - - http_request.go = function(self, ...) - -- Add trace headers before sending - if tracing.is_active() then - local url = self:get_uri() - local trace_headers = tracing.get_request_headers(url) - - for key, value in pairs(trace_headers) do - self:append_header(key, value) - end - end - - return original_go(self, ...) - end - - return http_request -end - ----Generic HTTP client wrapper that works with function-based clients ----@param http_client_func function HTTP client function (url, options) -> response ----@return function wrapped_client Wrapped HTTP client with tracing -function http_client.wrap_generic_client(http_client_func) - return function(url, options) - return tracing.wrap_http_request(http_client_func, url, options) - end -end - ----LuaSocket integration functions -http_client.luasocket = luasocket - ----lua-http integration functions -http_client.lua_http = lua_http - ----Auto-detection and wrapping of common HTTP clients ----@param http_module table The HTTP module to wrap ----@return table wrapped_module Wrapped HTTP module -function http_client.auto_wrap(http_module) - if not http_module then - return http_module - end - - -- Detect LuaSocket http module - if http_module.request and type(http_module.request) == "function" then - return luasocket.wrap_http_module(http_module) - end - - -- Detect lua-http request object - if http_module.get_headers_as_sequence and http_module.go then - return lua_http.wrap_request(http_module) - end - - -- Return unwrapped if not recognized - return http_module -end - ----Create a simple HTTP GET function with tracing support ----@param http_lib string? HTTP library to use ("luasocket", "lua-http", or auto-detect) ----@return function get_func HTTP GET function with tracing -function http_client.create_get_function(http_lib) - http_lib = http_lib or "luasocket" - - if http_lib == "luasocket" then - local http = require("socket.http") - local wrapped_http = luasocket.wrap_http_module(http) - - return function(url, headers) - local options = { - url = url, - headers = headers or {} - } - local body, status, response_headers = wrapped_http.request(options) - return { - body = body, - status = status, - headers = response_headers - } - end - end - - error("Unsupported HTTP library: " .. tostring(http_lib)) -end - ----Create a traced HTTP request function that can be used with any HTTP library ----@param make_request function Function that takes (url, options) and returns response ----@return function traced_request Function with automatic trace header injection -function http_client.create_traced_request(make_request) - if type(make_request) ~= "function" then - error("make_request must be a function") - end - - return function(url, options) - return tracing.wrap_http_request(make_request, url, options) - end -end - ----Middleware function for adding tracing to any HTTP client ----@param client_function function The original HTTP client function ----@param extract_url function? Optional function to extract URL from arguments ----@return function middleware_function Wrapped function with tracing -function http_client.create_middleware(client_function, extract_url) - if type(client_function) ~= "function" then - error("client_function must be a function") - end - - extract_url = extract_url or function(args) return args[1] end - - return function(...) - local args = {...} - local url = extract_url(args) - - if url and tracing.is_active() then - -- Modify headers in the arguments - -- This is library-specific and would need customization - local trace_headers = tracing.get_request_headers(url) - - -- Try to find headers in arguments - -- Check if first argument has headers (config object pattern) - if args[1] and type(args[1]) == "table" and args[1].headers then - args[1].headers = args[1].headers or {} - for key, value in pairs(trace_headers) do - args[1].headers[key] = value - end - -- Check if second argument contains headers (url, options pattern) - elseif args[2] and type(args[2]) == "table" then - args[2].headers = args[2].headers or {} - for key, value in pairs(trace_headers) do - args[2].headers[key] = value - end - end - end - - return client_function((table.unpack or unpack)(args)) - end -end - -return http_client \ No newline at end of file diff --git a/platforms/lua/http_server.lua b/platforms/lua/http_server.lua deleted file mode 100644 index 1bcefdf..0000000 --- a/platforms/lua/http_server.lua +++ /dev/null @@ -1,273 +0,0 @@ ----@class platforms.lua.http_server ---- HTTP server integrations for standard Lua with popular HTTP server libraries ---- Provides automatic trace continuation from incoming request headers - -local http_server = {} - -local tracing = require("sentry.tracing") - ----Pegasus.lua server integration ----@class PegasusIntegration -local pegasus = {} - ----Wrap Pegasus server start method with tracing ----@param pegasus_server table The Pegasus server instance ----@return table wrapped_server Wrapped server instance with tracing -function pegasus.wrap_server(pegasus_server) - if not pegasus_server or not pegasus_server.start then - error("Invalid Pegasus server - missing start method") - end - - local original_start = pegasus_server.start - - pegasus_server.start = function(self, handler_or_callback) - local wrapped_handler - - if type(handler_or_callback) == "function" then - -- Wrap the callback function - wrapped_handler = function(request, response) - -- Extract headers from Pegasus request object - local request_headers = {} - - if request and request.headers then - -- Pegasus stores headers in request.headers() function - if type(request.headers) == "function" then - local headers = request:headers() - if headers then - for key, value in pairs(headers) do - request_headers[key:lower()] = value - end - end - elseif type(request.headers) == "table" then - -- Fallback for other servers that use table - for key, value in pairs(request.headers) do - request_headers[key:lower()] = value - end - end - end - - -- Continue trace from request - tracing.continue_trace_from_request(request_headers) - - -- Call original handler - local success, result = pcall(handler_or_callback, request, response) - - if not success then - error(result) - end - - return result - end - else - wrapped_handler = handler_or_callback - end - - return original_start(self, wrapped_handler) - end - - return pegasus_server -end - ----Create a Pegasus middleware function for tracing ----@return function middleware_function Pegasus middleware that continues traces -function pegasus.create_middleware() - return function(request, response, next) - -- Extract headers - local request_headers = {} - if request and request.headers then - for key, value in pairs(request.headers) do - request_headers[key:lower()] = value - end - end - - -- Continue trace - tracing.continue_trace_from_request(request_headers) - - -- Call next middleware/handler - if next then - return next() - end - end -end - ----lua-http server integration ----@class LuaHttpServerIntegration -local lua_http_server = {} - ----Wrap lua-http server listen method with tracing ----@param server table The lua-http server instance ----@return table wrapped_server Wrapped server with tracing -function lua_http_server.wrap_server(server) - if not server or not server.listen then - error("Invalid lua-http server - missing listen method") - end - - local original_listen = server.listen - - server.listen = function(self, handler) - local wrapped_handler = function(stream) - -- lua-http provides headers via stream:get_headers() - local headers_sequence = stream:get_headers() - local request_headers = {} - - if headers_sequence then - -- Convert headers sequence to table - for name, value in headers_sequence:each() do - request_headers[name:lower()] = value - end - end - - -- Continue trace - tracing.continue_trace_from_request(request_headers) - - -- Call original handler - return handler(stream) - end - - return original_listen(self, wrapped_handler) - end - - return server -end - ----OpenResty/nginx-lua integration (for environments that support it) ----@class OpenRestyIntegration -local openresty = {} - ----Wrap OpenResty handler with tracing ----@param handler function The OpenResty request handler ----@return function wrapped_handler Handler that continues traces -function openresty.wrap_handler(handler) - return function(...) - -- Extract headers from nginx request - local request_headers = {} - - -- Check if we're in OpenResty environment - if ngx and ngx.req and ngx.req.get_headers then - local headers = ngx.req.get_headers() - for key, value in pairs(headers) do - request_headers[key:lower()] = value - end - end - - -- Continue trace - tracing.continue_trace_from_request(request_headers) - - -- Call original handler - return handler(...) - end -end - ----Generic HTTP server middleware creator ----@param extract_headers function Function that extracts headers from request object ----@return function middleware Middleware function for the specific server -function http_server.create_generic_middleware(extract_headers) - if type(extract_headers) ~= "function" then - error("extract_headers must be a function") - end - - return function(request, response, next) - local request_headers = extract_headers(request) - - -- Normalize headers to lowercase keys - local normalized_headers = {} - if request_headers then - for key, value in pairs(request_headers) do - if type(key) == "string" then - normalized_headers[key:lower()] = value - end - end - end - - -- Continue trace - tracing.continue_trace_from_request(normalized_headers) - - -- Call next if provided (middleware pattern) - if next and type(next) == "function" then - return next() - end - end -end - ----Wrap any server handler function with tracing ----@param handler function The original handler function ----@param extract_headers function Function to extract headers from request ----@return function wrapped_handler Handler with tracing support -function http_server.wrap_handler(handler, extract_headers) - if type(handler) ~= "function" then - error("handler must be a function") - end - - extract_headers = extract_headers or function(request) - -- Default header extraction (works with many libraries) - if request and request.headers then - return request.headers - end - return {} - end - - return function(request, response, ...) - local request_headers = extract_headers(request) - - -- Normalize headers - local normalized_headers = {} - if request_headers then - for key, value in pairs(request_headers) do - if type(key) == "string" then - normalized_headers[key:lower()] = value - end - end - end - - -- Continue trace - tracing.continue_trace_from_request(normalized_headers) - - -- Call original handler - return handler(request, response, ...) - end -end - ----Auto-detect and wrap server objects ----@param server table The server object to wrap ----@return table wrapped_server Wrapped server with tracing -function http_server.auto_wrap(server) - if not server then - return server - end - - -- Detect Pegasus server - if server.start and server.location then - return pegasus.wrap_server(server) - end - - -- Detect lua-http server - if server.listen and server.bind then - return lua_http_server.wrap_server(server) - end - - -- Return unwrapped if not recognized - return server -end - ----Create a simple HTTP server with tracing support ----@param server_type string Server type ("pegasus", "lua-http") ----@param config table Server configuration ----@return table server Configured server with tracing -function http_server.create_server(server_type, config) - config = config or {} - - if server_type == "pegasus" then - local pegasus_lib = require("pegasus") - local server = pegasus_lib:new(config) - return pegasus.wrap_server(server) - end - - error("Unsupported server type: " .. tostring(server_type)) -end - --- Export integration modules -http_server.pegasus = pegasus -http_server.lua_http_server = lua_http_server -http_server.openresty = openresty - -return http_server \ No newline at end of file diff --git a/platforms/lua/init.lua b/platforms/lua/init.lua deleted file mode 100644 index 2aaf949..0000000 --- a/platforms/lua/init.lua +++ /dev/null @@ -1,274 +0,0 @@ ----@class platforms.lua ---- Standard Lua and LuaJIT platform implementation for distributed tracing ---- Provides initialization and auto-detection of HTTP libraries - -local platform = {} - -local tracing = require("sentry.tracing") -local http_client = require("platforms.lua.http_client") -local http_server = require("platforms.lua.http_server") - ----Platform configuration -platform.config = { - name = "lua", - version = _VERSION or "Unknown", - auto_instrument = true, - supported_libraries = { - http_client = {"socket.http", "http.request"}, - http_server = {"pegasus", "http.server"} - } -} - ----Auto-instrumentation state -local auto_instrumentation_enabled = false -local instrumented_modules = {} - ----Initialize platform-specific tracing features ----@param options table? Platform initialization options -function platform.init(options) - options = options or {} - - -- Initialize core tracing - tracing.init(options.tracing) - - -- Enable auto-instrumentation if requested - if options.auto_instrument ~= false then - platform.enable_auto_instrumentation() - end - - -- Store platform-specific options - platform._options = options -end - ----Enable automatic instrumentation of HTTP libraries -function platform.enable_auto_instrumentation() - if auto_instrumentation_enabled then - return - end - - auto_instrumentation_enabled = true - - -- Auto-instrument HTTP client libraries - platform.auto_instrument_http_clients() - - -- Auto-instrument HTTP server libraries - platform.auto_instrument_http_servers() -end - ----Disable automatic instrumentation -function platform.disable_auto_instrumentation() - auto_instrumentation_enabled = false - - -- Restore original modules if they were instrumented - for module_name, original_module in pairs(instrumented_modules) do - package.loaded[module_name] = original_module - end - - instrumented_modules = {} -end - ----Auto-instrument HTTP client libraries -function platform.auto_instrument_http_clients() - -- Instrument LuaSocket HTTP module when required - local original_require = require - - require = function(module_name) - local module = original_require(module_name) - - -- Instrument socket.http when loaded - if module_name == "socket.http" and module and module.request then - if not instrumented_modules[module_name] then - instrumented_modules[module_name] = module - module = http_client.luasocket.wrap_http_module(module) - package.loaded[module_name] = module - end - end - - -- Instrument http.request (lua-http) when loaded - if module_name == "http.request" and module and module.new then - if not instrumented_modules[module_name] then - instrumented_modules[module_name] = module - - -- Wrap the constructor to return wrapped request objects - local original_new = module.new - module.new = function(...) - local request = original_new(...) - return http_client.lua_http.wrap_request(request) - end - - package.loaded[module_name] = module - end - end - - return module - end -end - ----Auto-instrument HTTP server libraries -function platform.auto_instrument_http_servers() - local original_require = require - - require = function(module_name) - local module = original_require(module_name) - - -- Instrument Pegasus when loaded - if module_name == "pegasus" and module and module.new then - if not instrumented_modules[module_name] then - instrumented_modules[module_name] = module - - -- Wrap the constructor to return wrapped server objects - local original_new = module.new - module.new = function(...) - local server = original_new(...) - return http_server.pegasus.wrap_server(server) - end - - package.loaded[module_name] = module - end - end - - return module - end -end - ----Manually instrument an HTTP client ----@param client table The HTTP client object or module ----@param client_type string? Type of client ("luasocket", "lua-http", or auto-detect) ----@return table instrumented_client Instrumented client -function platform.instrument_http_client(client, client_type) - if not client then - error("HTTP client is required") - end - - client_type = client_type or "auto" - - if client_type == "auto" then - return http_client.auto_wrap(client) - elseif client_type == "luasocket" then - return http_client.luasocket.wrap_http_module(client) - elseif client_type == "lua-http" then - return http_client.lua_http.wrap_request(client) - else - error("Unsupported HTTP client type: " .. tostring(client_type)) - end -end - ----Manually instrument an HTTP server ----@param server table The HTTP server object ----@param server_type string? Type of server ("pegasus", "lua-http", or auto-detect) ----@return table instrumented_server Instrumented server -function platform.instrument_http_server(server, server_type) - if not server then - error("HTTP server is required") - end - - server_type = server_type or "auto" - - if server_type == "auto" then - return http_server.auto_wrap(server) - elseif server_type == "pegasus" then - return http_server.pegasus.wrap_server(server) - elseif server_type == "lua-http" then - return http_server.lua_http_server.wrap_server(server) - else - error("Unsupported HTTP server type: " .. tostring(server_type)) - end -end - ----Create a traced HTTP client function ----@param client_type string? Type of client to create ("luasocket") ----@return function http_get HTTP GET function with tracing -function platform.create_http_client(client_type) - client_type = client_type or "luasocket" - - return http_client.create_get_function(client_type) -end - ----Create a traced HTTP server ----@param server_type string Type of server to create ("pegasus") ----@param config table? Server configuration ----@return table server HTTP server with tracing -function platform.create_http_server(server_type, config) - return http_server.create_server(server_type, config) -end - ----Wrap any function to continue traces from HTTP headers ----@param handler function The handler function to wrap ----@param extract_headers function? Custom header extraction function ----@return function wrapped_handler Handler with trace continuation -function platform.wrap_request_handler(handler, extract_headers) - return http_server.wrap_handler(handler, extract_headers) -end - ----Create middleware for popular frameworks ----@param framework string Framework type ("pegasus", "generic") ----@return function middleware Middleware function -function platform.create_middleware(framework) - if framework == "pegasus" then - return http_server.pegasus.create_middleware() - elseif framework == "generic" then - return http_server.create_generic_middleware(function(request) - return request.headers or {} - end) - else - error("Unsupported framework: " .. tostring(framework)) - end -end - ----Get platform information ----@return table info Platform information -function platform.get_info() - return { - name = platform.config.name, - version = platform.config.version, - auto_instrumentation_enabled = auto_instrumentation_enabled, - instrumented_modules = (function() - local keys = {} - for k, _ in pairs(instrumented_modules) do - table.insert(keys, k) - end - return keys - end)(), - tracing_active = tracing.is_active(), - supported_libraries = platform.config.supported_libraries - } -end - ----Check if a specific HTTP library is available ----@param library_name string Name of the library to check ----@return boolean available True if library is available -function platform.is_library_available(library_name) - local success, _ = pcall(require, library_name) - return success -end - ----Get recommended HTTP client for this platform ----@return string|nil client_type Recommended HTTP client type or nil if none available -function platform.get_recommended_http_client() - if platform.is_library_available("socket.http") then - return "luasocket" - elseif platform.is_library_available("http.request") then - return "lua-http" - end - - return nil -end - ----Get recommended HTTP server for this platform ----@return string|nil server_type Recommended HTTP server type or nil if none available -function platform.get_recommended_http_server() - if platform.is_library_available("pegasus") then - return "pegasus" - elseif platform.is_library_available("http.server") then - return "lua-http" - end - - return nil -end - --- Export core functionality for convenience -platform.tracing = tracing -platform.http_client = http_client -platform.http_server = http_server - -return platform \ No newline at end of file diff --git a/platforms/noop/init.lua b/platforms/noop/init.lua deleted file mode 100644 index 0b3d257..0000000 --- a/platforms/noop/init.lua +++ /dev/null @@ -1,162 +0,0 @@ ----@class platforms.noop ---- No-op platform implementation for unsupported platforms ---- Provides stub implementations that do nothing but don't error - -local noop = {} - ----Initialize no-op platform (does nothing) ----@param options table? Ignored options -function noop.init(options) - -- Do nothing - tracing not supported on this platform -end - ----No-op enable auto-instrumentation -function noop.enable_auto_instrumentation() - -- Do nothing -end - ----No-op disable auto-instrumentation -function noop.disable_auto_instrumentation() - -- Do nothing -end - ----No-op HTTP client instrumentation ----@param client table The HTTP client (returned unchanged) ----@param client_type string? Ignored ----@return table client Unchanged client -function noop.instrument_http_client(client, client_type) - return client -end - ----No-op HTTP server instrumentation ----@param server table The HTTP server (returned unchanged) ----@param server_type string? Ignored ----@return table server Unchanged server -function noop.instrument_http_server(server, server_type) - return server -end - ----No-op HTTP client creation ----@param client_type string? Ignored ----@return function noop_client Function that returns empty table -function noop.create_http_client(client_type) - return function(url, options) - return {} - end -end - ----No-op HTTP server creation ----@param server_type string Ignored ----@param config table? Ignored ----@return table noop_server Empty server object -function noop.create_http_server(server_type, config) - return {} -end - ----No-op request handler wrapper ----@param handler function The handler (returned unchanged) ----@param extract_headers function? Ignored ----@return function handler Unchanged handler -function noop.wrap_request_handler(handler, extract_headers) - return handler -end - ----No-op middleware creation ----@param framework string Ignored ----@return function noop_middleware Middleware that does nothing -function noop.create_middleware(framework) - return function(request, response, next) - if next then - return next() - end - end -end - ----Get platform information ----@return table info Platform information indicating no-op mode -function noop.get_info() - return { - name = "noop", - version = "1.0.0", - auto_instrumentation_enabled = false, - instrumented_modules = {}, - tracing_active = false, - supported_libraries = {}, - reason = "Tracing not supported on this platform" - } -end - ----Check if library is available (always false for no-op) ----@param library_name string Ignored ----@return boolean available Always false -function noop.is_library_available(library_name) - return false -end - ----Get recommended HTTP client (always nil for no-op) ----@return string|nil client_type Always nil -function noop.get_recommended_http_client() - return nil -end - ----Get recommended HTTP server (always nil for no-op) ----@return string|nil server_type Always nil -function noop.get_recommended_http_server() - return nil -end - --- Stub tracing module that does nothing -local noop_tracing = { - init = function() end, - continue_trace_from_request = function() return {} end, - get_request_headers = function() return {} end, - start_trace = function() return {} end, - create_child = function() return {} end, - get_current_trace_info = function() return nil end, - is_active = function() return false end, - clear = function() end, - attach_trace_context_to_event = function(event) return event end, - wrap_http_request = function(client, url, options) return client(url, options) end, - wrap_http_handler = function(handler) return handler end, - generate_ids = function() return {trace_id = "", span_id = ""} end, - get_envelope_trace_header = function() return nil end -} - --- Stub HTTP modules -local noop_http_client = { - wrap_generic_client = function(client) return client end, - luasocket = { - wrap_http_module = function(module) return module end - }, - lua_http = { - wrap_request = function(request) return request end - }, - auto_wrap = function(module) return module end, - create_get_function = function() return function() return {} end end, - create_traced_request = function(make_request) return make_request end, - create_middleware = function(client) return client end -} - -local noop_http_server = { - wrap_handler = function(handler) return handler end, - create_generic_middleware = function() return function() end end, - pegasus = { - wrap_server = function(server) return server end, - create_middleware = function() return function() end end - }, - lua_http_server = { - wrap_server = function(server) return server end - }, - openresty = { - wrap_handler = function(handler) return handler end - }, - auto_wrap = function(server) return server end, - create_server = function() return {} end -} - --- Export stub functionality -noop.tracing = noop_tracing -noop.http_client = noop_http_client -noop.http_server = noop_http_server - -return noop \ No newline at end of file diff --git a/scripts/bump-version.ps1 b/scripts/bump-version.ps1 index 0f64f2c..0814a37 100644 --- a/scripts/bump-version.ps1 +++ b/scripts/bump-version.ps1 @@ -63,7 +63,7 @@ if ($rockspec) { } # Update centralized version file -Replace-TextInFile "$repoRoot/src/sentry/version.tl" '(?<=VERSION = ").*?(?=")' $newVersion +Replace-TextInFile "$repoRoot/src/sentry/version.lua" '(?<=VERSION = ").*?(?=")' $newVersion # Update test spec files Replace-TextInFile "$repoRoot/spec/sentry_spec.lua" '(?<=sentry\.set_tag\("version", ").*?(?=")' $newVersion diff --git a/scripts/generate-roblox-all-in-one.sh b/scripts/generate-roblox-all-in-one.sh deleted file mode 100755 index d09d6af..0000000 --- a/scripts/generate-roblox-all-in-one.sh +++ /dev/null @@ -1,586 +0,0 @@ -#!/bin/bash -# -# Generate Roblox All-in-One Integration -# -# This script assembles a complete Roblox integration from the real SDK modules -# built from src/ (after Teal compilation). This ensures the example uses the -# actual SDK code and stays updated with SDK changes. -# -# Usage: ./scripts/generate-roblox-all-in-one.sh -# - -set -e - -echo "🔨 Generating Roblox All-in-One Integration from Real SDK" -echo "=======================================================" - -OUTPUT_FILE="examples/roblox/sentry-all-in-one.lua" - -# Check if SDK is built -if [ ! -f "build/sentry/init.lua" ]; then - echo "❌ SDK not built. Run 'make build' first." - exit 1 -fi - -echo "✅ Found built SDK" - -# Read required SDK modules -echo "📖 Reading SDK modules..." - -read_module() { - local file="$1" - if [ -f "$file" ]; then - echo "✅ Reading: $file" - cat "$file" - else - echo "❌ Missing: $file" - exit 1 - fi -} - -# Create the all-in-one file by combining real SDK modules -cat > "$OUTPUT_FILE" << 'HEADER_EOF' ---[[ - Sentry All-in-One for Roblox - - Complete Sentry integration using real SDK modules. - Generated from built SDK - DO NOT EDIT MANUALLY - - To regenerate: ./scripts/generate-roblox-all-in-one.sh - - USAGE: - 1. Copy this entire file - 2. Paste into ServerScriptService as a Script - 3. Update SENTRY_DSN below - 4. Enable HTTP requests: Game Settings → Security → "Allow HTTP Requests" - 5. Run the game (F5) - - API (same as other platforms): - sentry.init({dsn = "your-dsn"}) - sentry.capture_message("Player died!", "error") - sentry.capture_exception({type = "GameError", message = "Boss fight failed"}) - sentry.set_user({id = tostring(player.UserId), username = player.Name}) - sentry.set_tag("level", "10") - sentry.add_breadcrumb({message = "Player entered dungeon", category = "navigation"}) -]]-- - --- ⚠️ UPDATE THIS WITH YOUR SENTRY DSN -local SENTRY_DSN = "https://your-key@your-org.ingest.sentry.io/your-project-id" - -print("🚀 Starting Sentry All-in-One Integration") -print("DSN: ***" .. string.sub(SENTRY_DSN, -10)) -print("=" .. string.rep("=", 40)) - --- Embedded SDK Modules (from real build/) --- This ensures we use the actual SDK code with proper version info - -HEADER_EOF - -# Add version module -echo "" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "-- VERSION MODULE (from build/sentry/version.lua)" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "" >> "$OUTPUT_FILE" - -# Read version and create local version -VERSION=$(grep -o '"[^"]*"' build/sentry/version.lua | tr -d '"') -cat >> "$OUTPUT_FILE" << VERSION_EOF -local function version() - return "$VERSION" -end -VERSION_EOF - -# Add JSON utils -echo "" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "-- JSON UTILS (from build/sentry/utils/json.lua)" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "" >> "$OUTPUT_FILE" - -# For Roblox, we use HttpService for JSON, so create a simple wrapper -cat >> "$OUTPUT_FILE" << 'JSON_EOF' -local json = {} -local HttpService = game:GetService("HttpService") - -function json.encode(obj) - return HttpService:JSONEncode(obj) -end - -function json.decode(str) - return HttpService:JSONDecode(str) -end -JSON_EOF - -# Add DSN utils (extract the core functions we need) -echo "" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "-- DSN UTILS (adapted from build/sentry/utils/dsn.lua)" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "" >> "$OUTPUT_FILE" - -cat >> "$OUTPUT_FILE" << 'DSN_EOF' -local dsn_utils = {} - -function dsn_utils.parse_dsn(dsn_string) - if not dsn_string or dsn_string == "" then - return {}, "DSN is required" - end - - local protocol, credentials, host_path = dsn_string:match("^(https?)://([^@]+)@(.+)$") - - if not protocol or not credentials or not host_path then - return {}, "Invalid DSN format" - end - - -- Parse credentials (public_key or public_key:secret_key) - local public_key, secret_key = credentials:match("^([^:]+):(.+)$") - if not public_key then - public_key = credentials - secret_key = "" - end - - if not public_key or public_key == "" then - return {}, "Invalid DSN format" - end - - -- Parse host and path - local host, path = host_path:match("^([^/]+)(.*)$") - if not host or not path or path == "" then - return {}, "Invalid DSN format" - end - - -- Extract project ID from path (last numeric segment) - local project_id = path:match("/([%d]+)$") - if not project_id then - return {}, "Could not extract project ID from DSN" - end - - return { - protocol = protocol, - public_key = public_key, - secret_key = secret_key or "", - host = host, - path = path, - project_id = project_id - }, nil -end - -function dsn_utils.build_ingest_url(dsn) - return "https://" .. dsn.host .. "/api/" .. dsn.project_id .. "/store/" -end - -function dsn_utils.build_auth_header(dsn) - return string.format("Sentry sentry_version=7, sentry_key=%s, sentry_client=sentry-lua/%s", - dsn.public_key, version()) -end -DSN_EOF - -# Add Roblox Transport (from the real built module) -echo "" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "-- ROBLOX TRANSPORT (from build/sentry/platforms/roblox/transport.lua)" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "" >> "$OUTPUT_FILE" - -# Extract the core transport logic and adapt for standalone use -cat >> "$OUTPUT_FILE" << 'TRANSPORT_EOF' -local RobloxTransport = {} -RobloxTransport.__index = RobloxTransport - -function RobloxTransport:new() - local transport = setmetatable({ - dsn = nil, - endpoint = nil, - headers = nil - }, RobloxTransport) - return transport -end - -function RobloxTransport:configure(config) - local dsn, err = dsn_utils.parse_dsn(config.dsn or "") - if err then - error("Invalid DSN: " .. err) - end - - self.dsn = dsn - self.endpoint = dsn_utils.build_ingest_url(dsn) - self.headers = { - ["X-Sentry-Auth"] = dsn_utils.build_auth_header(dsn), - } - - -- Configuration successful - - return self -end - -function RobloxTransport:send(event) - if not game then - return false, "Not in Roblox environment" - end - - local success_service, HttpService = pcall(function() - return game:GetService("HttpService") - end) - - if not success_service or not HttpService then - return false, "HttpService not available in Roblox" - end - - local body = json.encode(event) - - local success, response = pcall(function() - return HttpService:PostAsync(self.endpoint, body, - Enum.HttpContentType.ApplicationJson, - false, - self.headers) - end) - - if success then - return true, "Event sent via Roblox HttpService" - else - return false, "Roblox HTTP error: " .. tostring(response) - end -end -TRANSPORT_EOF - -# Add Scope (simplified from the real SDK) -echo "" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "-- SCOPE (from build/sentry/core/scope.lua)" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "" >> "$OUTPUT_FILE" - -cat >> "$OUTPUT_FILE" << 'SCOPE_EOF' -local Scope = {} -Scope.__index = Scope - -function Scope:new() - return setmetatable({ - user = nil, - tags = {}, - extra = {}, - breadcrumbs = {}, - level = nil - }, Scope) -end - -function Scope:set_user(user) - self.user = user -end - -function Scope:set_tag(key, value) - self.tags[key] = tostring(value) -end - -function Scope:set_extra(key, value) - self.extra[key] = value -end - -function Scope:add_breadcrumb(breadcrumb) - breadcrumb.timestamp = os.time() - table.insert(self.breadcrumbs, breadcrumb) - - -- Keep only last 50 breadcrumbs - if #self.breadcrumbs > 50 then - table.remove(self.breadcrumbs, 1) - end -end - -function Scope:clone() - local cloned = Scope:new() - cloned.user = self.user - cloned.level = self.level - - -- Deep copy tables - for k, v in pairs(self.tags) do - cloned.tags[k] = v - end - for k, v in pairs(self.extra) do - cloned.extra[k] = v - end - for i, crumb in ipairs(self.breadcrumbs) do - cloned.breadcrumbs[i] = crumb - end - - return cloned -end -SCOPE_EOF - -# Add Client (adapted from real SDK) -echo "" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "-- CLIENT (from build/sentry/core/client.lua)" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "" >> "$OUTPUT_FILE" - -cat >> "$OUTPUT_FILE" << 'CLIENT_EOF' -local Client = {} -Client.__index = Client - -function Client:new(config) - if not config.dsn then - error("DSN is required") - end - - local client = setmetatable({ - transport = RobloxTransport:new(), - scope = Scope:new(), - config = config - }, Client) - - client.transport:configure(config) - - -- Client initialized successfully - - return client -end - -function Client:capture_message(message, level) - level = level or "info" - - local event = { - message = { - message = message - }, - level = level, - timestamp = os.time(), - environment = self.config.environment or "production", - release = self.config.release or "unknown", - platform = "roblox", - sdk = { - name = "sentry.lua", - version = version() - }, - server_name = "roblox-server", - user = self.scope.user, - tags = self.scope.tags, - extra = self.scope.extra, - breadcrumbs = self.scope.breadcrumbs, - contexts = { - roblox = { - version = version(), - place_id = tostring(game.PlaceId), - job_id = game.JobId or "unknown" - } - } - } - - return self.transport:send(event) -end - -function Client:capture_exception(exception, level) - level = level or "error" - - local event = { - exception = { - values = { - { - type = exception.type or "RobloxError", - value = exception.message or tostring(exception) - } - } - }, - level = level, - timestamp = os.time(), - environment = self.config.environment or "production", - release = self.config.release or "unknown", - platform = "roblox", - sdk = { - name = "sentry.lua", - version = version() - }, - server_name = "roblox-server", - user = self.scope.user, - tags = self.scope.tags, - extra = self.scope.extra, - breadcrumbs = self.scope.breadcrumbs, - contexts = { - roblox = { - version = version(), - place_id = tostring(game.PlaceId), - job_id = game.JobId or "unknown" - } - } - } - - return self.transport:send(event) -end - -function Client:set_user(user) - self.scope:set_user(user) -end - -function Client:set_tag(key, value) - self.scope:set_tag(key, value) -end - -function Client:set_extra(key, value) - self.scope:set_extra(key, value) -end - -function Client:add_breadcrumb(breadcrumb) - self.scope:add_breadcrumb(breadcrumb) -end -CLIENT_EOF - -# Add main Sentry API (from real SDK) -echo "" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "-- MAIN SENTRY API (from build/sentry/init.lua)" >> "$OUTPUT_FILE" -echo "-- ============================================================================" >> "$OUTPUT_FILE" -echo "" >> "$OUTPUT_FILE" - -cat >> "$OUTPUT_FILE" << 'SENTRY_EOF' -local sentry = {} - -function sentry.init(config) - if not config or not config.dsn then - error("Sentry DSN is required") - end - - sentry._client = Client:new(config) - return sentry._client -end - -function sentry.capture_message(message, level) - if not sentry._client then - error("Sentry not initialized. Call sentry.init() first.") - end - - return sentry._client:capture_message(message, level) -end - -function sentry.capture_exception(exception, level) - if not sentry._client then - error("Sentry not initialized. Call sentry.init() first.") - end - - return sentry._client:capture_exception(exception, level) -end - -function sentry.set_user(user) - if sentry._client then - sentry._client:set_user(user) - end -end - -function sentry.set_tag(key, value) - if sentry._client then - sentry._client:set_tag(key, value) - end -end - -function sentry.set_extra(key, value) - if sentry._client then - sentry._client:set_extra(key, value) - end -end - -function sentry.add_breadcrumb(breadcrumb) - if sentry._client then - sentry._client:add_breadcrumb(breadcrumb) - end -end - -function sentry.flush() - -- No-op for Roblox (HTTP is immediate) -end - -function sentry.close() - if sentry._client then - sentry._client = nil - end -end -SENTRY_EOF - -# Add initialization and test code -cat >> "$OUTPUT_FILE" << 'INIT_EOF' - --- Initialize Sentry with provided DSN -sentry.init({ - dsn = SENTRY_DSN, - environment = "roblox-production", - release = "1.0.0" -}) - --- Run integration tests -sentry.capture_message("Sentry integration test", "info") -sentry.capture_exception({ - type = "IntegrationTestError", - message = "Test exception from Roblox all-in-one integration" -}) - --- Make sentry available globally for easy access with multiple methods -_G.sentry = sentry - --- Also store in shared (if available) -if shared then - shared.sentry = sentry -end - --- Store in getgenv if available (common in executors) -if getgenv then - getgenv().sentry = sentry -end - --- Store in game.ReplicatedStorage for cross-script access -if game and game:GetService("ReplicatedStorage") then - local replicatedStorage = game:GetService("ReplicatedStorage") - if not replicatedStorage:FindFirstChild("SentrySDK") then - local sentryValue = Instance.new("ObjectValue") - sentryValue.Name = "SentrySDK" - sentryValue.Parent = replicatedStorage - sentryValue:SetAttribute("Initialized", true) - end -end - --- Store in workspace as well for fallback -if game and game:FindFirstChild("Workspace") then - local workspace = game.Workspace - if not workspace:FindFirstChild("SentrySDK") then - local sentryObject = Instance.new("ObjectValue") - sentryObject.Name = "SentrySDK" - sentryObject.Parent = workspace - end - -- Store actual reference in a persistent way - workspace.SentrySDK:SetAttribute("Initialized", true) -end - --- Force global persistence -rawset(_G, "sentry", sentry) - --- Sentry SDK is now available globally - -print("✅ Sentry integration ready - SDK " .. version()) -print("💡 Use: _G.sentry.capture_message('Hello', 'info')") - --- Also try alternative global setups for better Roblox compatibility -if getgenv then - getgenv().sentry = sentry -end - --- Set up a test function that can be called easily -_G.testSentry = function() - _G.sentry.capture_message("Test from _G.testSentry() function", "info") - print("✅ Test message sent!") -end -INIT_EOF - -echo "✅ Generated $OUTPUT_FILE" - -# Get file size -FILE_SIZE=$(wc -c < "$OUTPUT_FILE") -FILE_SIZE_KB=$((FILE_SIZE / 1024)) -echo "📊 File size: ${FILE_SIZE_KB} KB" -echo "📦 SDK version: $VERSION" - -echo "" -echo "🎉 Generation completed successfully!" -echo "" -echo "📋 The all-in-one file is ready for use:" -echo " • Uses real SDK modules from build/" -echo " • Proper SDK version: $VERSION" -echo " • Standard API: sentry.capture_message(), sentry.set_tag(), etc." -echo " • Copy $OUTPUT_FILE into Roblox Studio" -echo " • Update the SENTRY_DSN variable and test" \ No newline at end of file diff --git a/sentry-0.0.6-1.rockspec b/sentry-0.0.6-1.rockspec index e484092..e9add07 100644 --- a/sentry-0.0.6-1.rockspec +++ b/sentry-0.0.6-1.rockspec @@ -1,74 +1,22 @@ rockspec_format = "3.0" -package = "sentry" +package = "sdk" version = "0.0.6-1" source = { url = "git+https://github.com/getsentry/sentry-lua.git", tag = "0.0.6" } description = { - summary = "Platform-agnostic Sentry SDK for Lua", + summary = "Sentry SDK for Lua", detailed = [[ - A comprehensive Sentry SDK for Lua environments with distributed tracing, - structured logging, and cross-platform support. Written in Teal Language - for type safety and compiled to Lua during installation. + A Sentry SDK for Lua focus on portability ]], homepage = "https://github.com/getsentry/sentry-lua", license = "MIT" } dependencies = { "lua >= 5.1", - "lua-cjson", "luasocket" } -build_dependencies = { - "tl" -} build = { type = "command", - build_command = [[ - echo "=== Starting Teal compilation ===" - - # Create build directory structure - mkdir -p build/sentry/core - mkdir -p build/sentry/logger - mkdir -p build/sentry/performance - mkdir -p build/sentry/platforms/defold - mkdir -p build/sentry/platforms/love2d - mkdir -p build/sentry/platforms/nginx - mkdir -p build/sentry/platforms/redis - mkdir -p build/sentry/platforms/roblox - mkdir -p build/sentry/platforms/standard - mkdir -p build/sentry/platforms/test - mkdir -p build/sentry/tracing - mkdir -p build/sentry/utils - - # Compile all Teal files to Lua - find src/sentry -name "*.tl" -type f | while read -r tl_file; do - lua_file=$(echo "$tl_file" | sed 's|^src/|build/|' | sed 's|\.tl$|.lua|') - echo "Compiling: $tl_file -> $lua_file" - tl gen "$tl_file" -o "$lua_file" - if [ $? -ne 0 ]; then - echo "ERROR: Failed to compile $tl_file" - exit 1 - fi - done - - echo "=== Teal compilation completed ===" - ]], - install_command = [[ - echo "=== Installing compiled Lua files ===" - - # Create target directory - mkdir -p "$(LUADIR)/sentry" - - # Copy all compiled Lua files preserving directory structure - cd build && find sentry -name "*.lua" -type f | while read -r lua_file; do - target_dir=$(dirname "$(LUADIR)/$lua_file") - mkdir -p "$target_dir" - cp "$lua_file" "$(LUADIR)/$lua_file" - echo "Installed: $lua_file -> $(LUADIR)/$lua_file" - done - - echo "=== Installation completed ===" - ]] } \ No newline at end of file diff --git a/spec/baggage_headers_spec.lua b/spec/baggage_headers_spec.lua deleted file mode 100644 index bbaeb57..0000000 --- a/spec/baggage_headers_spec.lua +++ /dev/null @@ -1,190 +0,0 @@ --- Tests for baggage header parsing and generation --- Based on W3C Baggage specification and Sentry usage - -local headers = require("sentry.tracing.headers") - -describe("baggage header", function() - describe("parsing", function() - it("should parse single key-value pair", function() - local header = "sentry-environment=production" - local result = headers.parse_baggage(header) - - assert.is_not_nil(result) - assert.are.equal("production", result["sentry-environment"]) - end) - - it("should parse multiple key-value pairs", function() - local header = "sentry-environment=production,sentry-release=1.0.0,user-id=123" - local result = headers.parse_baggage(header) - - assert.is_not_nil(result) - assert.are.equal("production", result["sentry-environment"]) - assert.are.equal("1.0.0", result["sentry-release"]) - assert.are.equal("123", result["user-id"]) - end) - - it("should handle whitespace around key-value pairs", function() - local header = " sentry-environment=production , sentry-release=1.0.0 " - local result = headers.parse_baggage(header) - - assert.is_not_nil(result) - assert.are.equal("production", result["sentry-environment"]) - assert.are.equal("1.0.0", result["sentry-release"]) - end) - - it("should ignore properties after semicolon", function() - local header = "sentry-environment=production;properties=ignored,sentry-release=1.0.0" - local result = headers.parse_baggage(header) - - assert.is_not_nil(result) - assert.are.equal("production", result["sentry-environment"]) - assert.are.equal("1.0.0", result["sentry-release"]) - end) - - it("should handle empty values", function() - local header = "sentry-environment=,sentry-release=1.0.0" - local result = headers.parse_baggage(header) - - assert.is_not_nil(result) - assert.are.equal("", result["sentry-environment"]) - assert.are.equal("1.0.0", result["sentry-release"]) - end) - - it("should skip malformed entries", function() - local header = "sentry-environment=production,malformed,sentry-release=1.0.0" - local result = headers.parse_baggage(header) - - assert.is_not_nil(result) - assert.are.equal("production", result["sentry-environment"]) - assert.are.equal("1.0.0", result["sentry-release"]) - assert.is_nil(result["malformed"]) - end) - - it("should handle nil input", function() - local result = headers.parse_baggage(nil) - assert.is_not_nil(result) - - -- Count the number of keys in the table - local count = 0 - for _ in pairs(result) do - count = count + 1 - end - assert.are.equal(0, count) - end) - - it("should handle empty string", function() - local result = headers.parse_baggage("") - assert.is_not_nil(result) - assert.are.equal("table", type(result)) - end) - - it("should handle non-string input", function() - local result = headers.parse_baggage(123) - assert.is_not_nil(result) - assert.are.equal("table", type(result)) - end) - end) - - describe("generation", function() - it("should generate baggage header for single entry", function() - local baggage = { - ["sentry-environment"] = "production" - } - - local result = headers.generate_baggage(baggage) - assert.are.equal("sentry-environment=production", result) - end) - - it("should generate baggage header for multiple entries", function() - local baggage = { - ["sentry-environment"] = "production", - ["sentry-release"] = "1.0.0" - } - - local result = headers.generate_baggage(baggage) - assert.is_not_nil(result) - assert.is_not_nil(result:match("sentry%-environment=production")) - assert.is_not_nil(result:match("sentry%-release=1%.0%.0")) - assert.is_not_nil(result:match(",")) - end) - - it("should URL encode special characters", function() - local baggage = { - ["test-key"] = "value,with;special=characters%" - } - - local result = headers.generate_baggage(baggage) - assert.is_not_nil(result) - assert.is_not_nil(result:match("%%2C")) -- encoded comma - assert.is_not_nil(result:match("%%3B")) -- encoded semicolon - assert.is_not_nil(result:match("%%3D")) -- encoded equals - assert.is_not_nil(result:match("%%25")) -- encoded percent - end) - - it("should handle empty values", function() - local baggage = { - ["empty-key"] = "" - } - - local result = headers.generate_baggage(baggage) - assert.are.equal("empty-key=", result) - end) - - it("should return nil for empty baggage", function() - local baggage = {} - local result = headers.generate_baggage(baggage) - assert.is_nil(result) - end) - - it("should return nil for nil input", function() - local result = headers.generate_baggage(nil) - assert.is_nil(result) - end) - - it("should return nil for non-table input", function() - local result = headers.generate_baggage("invalid") - assert.is_nil(result) - end) - - it("should skip non-string keys and values", function() - local baggage = { - ["valid-key"] = "valid-value", - [123] = "invalid-key", - ["invalid-value"] = 456 - } - - local result = headers.generate_baggage(baggage) - assert.are.equal("valid-key=valid-value", result) - end) - end) - - describe("round-trip consistency", function() - it("should parse what it generates", function() - local original = { - ["sentry-environment"] = "production", - ["sentry-release"] = "1.0.0", - ["user-id"] = "123" - } - - local header = headers.generate_baggage(original) - local parsed = headers.parse_baggage(header) - - assert.are.equal(original["sentry-environment"], parsed["sentry-environment"]) - assert.are.equal(original["sentry-release"], parsed["sentry-release"]) - assert.are.equal(original["user-id"], parsed["user-id"]) - end) - - it("should handle special characters in round-trip", function() - local original = { - ["test-key"] = "value,with;special=characters%" - } - - local header = headers.generate_baggage(original) - local parsed = headers.parse_baggage(header) - - -- Note: Basic implementation may not handle URL decoding perfectly - -- This test documents expected behavior - assert.is_not_nil(parsed["test-key"]) - end) - end) -end) \ No newline at end of file diff --git a/spec/context_filtering_spec.lua b/spec/context_filtering_spec.lua deleted file mode 100644 index 461978e..0000000 --- a/spec/context_filtering_spec.lua +++ /dev/null @@ -1,159 +0,0 @@ -describe("Context Filtering", function() - local Client - local Scope - local os_utils - local original_get_os_info - - before_each(function() - Client = require("sentry.core.client") - Scope = require("sentry.core.scope") - os_utils = require("sentry.utils.os") - - -- Mock get_os_info function - original_get_os_info = os_utils.get_os_info - end) - - after_each(function() - -- Restore original function - if original_get_os_info then - os_utils.get_os_info = original_get_os_info - end - end) - - describe("OS context with nil version", function() - it("should exclude version field when nil", function() - -- Mock OS detection to return nil version - os_utils.get_os_info = function() - return {name = "TestOS", version = nil} - end - - local client = Client:new({ - dsn = "https://test@sentry.io/123456", - test_transport = true - }) - - -- Check that OS context was set correctly - assert.is_not_nil(client.scope) - assert.is_not_nil(client.scope.contexts.os) - assert.are.equal("TestOS", client.scope.contexts.os.name) - assert.is_nil(client.scope.contexts.os.version) - end) - - it("should include version field when present", function() - -- Mock OS detection to return valid version - os_utils.get_os_info = function() - return {name = "macOS", version = "15.5"} - end - - local client = Client:new({ - dsn = "https://test@sentry.io/123456", - test_transport = true - }) - - -- Check that OS context includes version - assert.are.equal("macOS", client.scope.contexts.os.name) - assert.are.equal("15.5", client.scope.contexts.os.version) - end) - - it("should not set OS context when detection fails", function() - -- Mock OS detection to return nil - os_utils.get_os_info = function() - return nil - end - - local client = Client:new({ - dsn = "https://test@sentry.io/123456", - test_transport = true - }) - - -- Check that no OS context was set - assert.is_nil(client.scope.contexts.os) - end) - end) - - describe("Scope set_context", function() - local scope - - before_each(function() - scope = Scope:new() - end) - - it("should store context with all fields", function() - scope:set_context("test", { - name = "TestContext", - version = "1.0", - description = "Test description" - }) - - assert.are.equal("TestContext", scope.contexts.test.name) - assert.are.equal("1.0", scope.contexts.test.version) - assert.are.equal("Test description", scope.contexts.test.description) - end) - - it("should store context with nil fields", function() - scope:set_context("test", { - name = "TestContext", - version = nil, - description = "Test description" - }) - - assert.are.equal("TestContext", scope.contexts.test.name) - assert.is_nil(scope.contexts.test.version) - assert.are.equal("Test description", scope.contexts.test.description) - end) - - it("should overwrite existing context", function() - scope:set_context("test", {name = "Original"}) - scope:set_context("test", {name = "Updated", version = "2.0"}) - - assert.are.equal("Updated", scope.contexts.test.name) - assert.are.equal("2.0", scope.contexts.test.version) - end) - end) - - describe("Event context application", function() - local scope - - before_each(function() - scope = Scope:new() - end) - - it("should apply contexts to event", function() - scope:set_context("os", {name = "TestOS", version = "1.0"}) - scope:set_context("runtime", {name = "lua", version = "5.4"}) - - local event = {} - local updated_event = scope:apply_to_event(event) - - assert.is_not_nil(updated_event.contexts) - assert.are.equal("TestOS", updated_event.contexts.os.name) - assert.are.equal("lua", updated_event.contexts.runtime.name) - end) - - it("should preserve existing event contexts", function() - scope:set_context("os", {name = "TestOS"}) - - local event = { - contexts = { - existing = {name = "Existing"} - } - } - - local updated_event = scope:apply_to_event(event) - - -- Should have both existing and new contexts - assert.are.equal("Existing", updated_event.contexts.existing.name) - assert.are.equal("TestOS", updated_event.contexts.os.name) - end) - - it("should handle empty scope contexts", function() - local event = {message = "test"} - local updated_event = scope:apply_to_event(event) - - -- Event should be unchanged when scope has no contexts - assert.are.equal("test", updated_event.message) - -- Should not add empty contexts object - -- (depends on implementation - might be nil or empty) - end) - end) -end) \ No newline at end of file diff --git a/spec/dsn_parsing_spec.lua b/spec/dsn_parsing_spec.lua deleted file mode 100644 index e5a3e0c..0000000 --- a/spec/dsn_parsing_spec.lua +++ /dev/null @@ -1,258 +0,0 @@ -describe("DSN Parsing", function() - local dsn_utils - - before_each(function() - dsn_utils = require("sentry.utils.dsn") - end) - - describe("Valid DSN parsing", function() - it("should parse a standard valid DSN with secret", function() - local dsn_string = "https://public_key:secret_key@sentry.io/123456" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("https", dsn.protocol) - assert.are.equal("public_key", dsn.public_key) - assert.are.equal("secret_key", dsn.secret_key) - assert.are.equal("sentry.io", dsn.host) - assert.are.equal(443, dsn.port) - assert.are.equal("/123456", dsn.path) - assert.are.equal("123456", dsn.project_id) - end) - - it("should parse a valid DSN without secret key", function() - local dsn_string = "https://public_key@sentry.io/789" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("https", dsn.protocol) - assert.are.equal("public_key", dsn.public_key) - assert.are.equal("", dsn.secret_key) - assert.are.equal("sentry.io", dsn.host) - assert.are.equal("789", dsn.project_id) - end) - - it("should parse HTTP DSN with default port 80", function() - local dsn_string = "http://public_key@localhost/456" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("http", dsn.protocol) - assert.are.equal("localhost", dsn.host) - assert.are.equal(80, dsn.port) - assert.are.equal("456", dsn.project_id) - end) - - it("should parse DSN with custom port", function() - local dsn_string = "https://public_key@sentry.example.com:9000/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("sentry.example.com", dsn.host) - assert.are.equal(9000, dsn.port) - end) - - it("should parse DSN with path prefix", function() - local dsn_string = "https://public_key@sentry.io/path/to/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("/path/to/123", dsn.path) - assert.are.equal("123", dsn.project_id) - end) - - it("should parse DSN with subdomain", function() - local dsn_string = "https://abc123@org.ingest.sentry.io/456789" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("abc123", dsn.public_key) - assert.are.equal("org.ingest.sentry.io", dsn.host) - assert.are.equal("456789", dsn.project_id) - end) - - it("should handle numeric project IDs correctly", function() - local dsn_string = "https://key@host.com/1234567890" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("1234567890", dsn.project_id) - end) - end) - - describe("Invalid DSN parsing", function() - it("should reject nil DSN", function() - local dsn, error = dsn_utils.parse_dsn(nil) - - assert.is_not_nil(error) - assert.are.equal("DSN is required", error) - end) - - it("should reject empty string DSN", function() - local dsn, error = dsn_utils.parse_dsn("") - - assert.is_not_nil(error) - assert.are.equal("DSN is required", error) - end) - - it("should reject DSN without protocol", function() - local dsn, error = dsn_utils.parse_dsn("public_key@sentry.io/123") - - assert.is_not_nil(error) - assert.are.equal("Invalid DSN format", error) - end) - - it("should reject DSN without public key", function() - local dsn, error = dsn_utils.parse_dsn("https://@sentry.io/123") - - assert.is_not_nil(error) - assert.are.equal("Invalid DSN format", error) - end) - - it("should reject DSN without host", function() - local dsn, error = dsn_utils.parse_dsn("https://public_key@/123") - - assert.is_not_nil(error) - assert.are.equal("Invalid DSN format", error) - end) - - it("should reject DSN without path", function() - local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io") - - assert.is_not_nil(error) - assert.are.equal("Invalid DSN format", error) - end) - - it("should reject DSN without project ID", function() - local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/") - - assert.is_not_nil(error) - assert.are.equal("Could not extract project ID from DSN", error) - end) - - it("should reject DSN with non-numeric project ID", function() - local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/project") - - assert.is_not_nil(error) - assert.are.equal("Could not extract project ID from DSN", error) - end) - - it("should reject malformed URLs", function() - local malformed_dsns = { - "not-a-url", - "://missing-scheme", - "https://", - "https:///path/only", - "ftp://wrong-scheme@host.com/123", - } - - for _, dsn_string in ipairs(malformed_dsns) do - local dsn, error = dsn_utils.parse_dsn(dsn_string) - assert.is_not_nil(error, "Expected error for DSN: " .. dsn_string) - end - end) - - it("should handle invalid port gracefully", function() - -- This tests the port parsing - invalid ports should fallback to default - local dsn_string = "https://key@host.com:invalid/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal(443, dsn.port) -- Should fallback to default HTTPS port - end) - end) - - describe("URL building", function() - it("should build correct ingest URL", function() - local dsn = { - protocol = "https", - host = "sentry.io", - project_id = "123456" - } - - local url = dsn_utils.build_ingest_url(dsn) - assert.are.equal("https://sentry.io/api/123456/store/", url) - end) - - it("should build correct ingest URL for HTTP", function() - local dsn = { - protocol = "http", - host = "localhost", - project_id = "789" - } - - local url = dsn_utils.build_ingest_url(dsn) - assert.are.equal("http://localhost/api/789/store/", url) - end) - end) - - describe("Auth header building", function() - it("should build auth header with secret key", function() - local dsn = { - public_key = "public123", - secret_key = "secret456" - } - - local header = dsn_utils.build_auth_header(dsn) - - -- Check that all required parts are present (version-agnostic) - assert.is_true(header:find("Sentry sentry_version=7") ~= nil) - assert.is_true(header:find("sentry_key=public123") ~= nil) - assert.is_true(header:find("sentry_secret=secret456") ~= nil) - assert.is_true(header:find("sentry_client=sentry%-lua/") ~= nil) -- Version-agnostic check - end) - - it("should build auth header without secret key", function() - local dsn = { - public_key = "public123", - secret_key = "" - } - - local header = dsn_utils.build_auth_header(dsn) - - -- Should not include secret - assert.is_true(header:find("sentry_key=public123") ~= nil) - assert.is_true(header:find("sentry_secret") == nil) - end) - - it("should build auth header with nil secret key", function() - local dsn = { - public_key = "public123", - secret_key = nil - } - - local header = dsn_utils.build_auth_header(dsn) - - -- Should not include secret - assert.is_true(header:find("sentry_key=public123") ~= nil) - assert.is_true(header:find("sentry_secret") == nil) - end) - end) - - describe("Edge cases and security", function() - it("should handle special characters in keys", function() - local dsn_string = "https://key-with.dots_and-dashes@host.com/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("key-with.dots_and-dashes", dsn.public_key) - end) - - it("should handle IPv4 addresses", function() - local dsn_string = "https://key@192.168.1.1/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("192.168.1.1", dsn.host) - end) - - it("should handle very long project IDs", function() - local long_id = "123456789012345678901234567890" - local dsn_string = "https://key@host.com/" .. long_id - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal(long_id, dsn.project_id) - end) - end) -end) \ No newline at end of file diff --git a/spec/logger_spec.lua b/spec/logger_spec.lua deleted file mode 100644 index 28c813e..0000000 --- a/spec/logger_spec.lua +++ /dev/null @@ -1,404 +0,0 @@ -local logger = require("sentry.logger") -local envelope = require("sentry.utils.envelope") -local json = require("sentry.utils.json") - -describe("sentry.logger", function() - - before_each(function() - -- Reset logger state - if logger.unhook_print then - logger.unhook_print() - end - - -- Clear any existing configuration - logger.init({ - enable_logs = false, - hook_print = false - }) - end) - - describe("initialization", function() - it("should initialize with default config", function() - logger.init({ - enable_logs = true - }) - - local config = logger.get_config() - assert.is_true(config.enable_logs) - assert.equals(100, config.max_buffer_size) - assert.equals(5.0, config.flush_timeout) - assert.is_false(config.hook_print) - end) - - it("should initialize with custom config", function() - logger.init({ - enable_logs = true, - max_buffer_size = 50, - flush_timeout = 10.0, - hook_print = true - }) - - local config = logger.get_config() - assert.is_true(config.enable_logs) - assert.equals(50, config.max_buffer_size) - assert.equals(10.0, config.flush_timeout) - assert.is_true(config.hook_print) - end) - - it("should support before_send_log hook", function() - local hook_called = false - local modified_body = "modified message" - - logger.init({ - enable_logs = true, - before_send_log = function(log_record) - hook_called = true - log_record.body = modified_body - return log_record - end - }) - - logger.info("original message") - logger.flush() - - assert.is_true(hook_called) - end) - - it("should filter logs when before_send_log returns nil", function() - logger.init({ - enable_logs = true, - before_send_log = function(log_record) - if log_record.level == "debug" then - return nil -- Filter debug logs - end - return log_record - end - }) - - logger.debug("This should be filtered") - logger.info("This should pass through") - - local status = logger.get_buffer_status() - assert.equals(1, status.logs) -- Only info log should remain - end) - end) - - describe("logging levels", function() - before_each(function() - logger.init({ - enable_logs = true, - max_buffer_size = 10 - }) - end) - - it("should support all log levels", function() - logger.trace("trace message") - logger.debug("debug message") - logger.info("info message") - logger.warn("warn message") - logger.error("error message") - logger.fatal("fatal message") - - local status = logger.get_buffer_status() - assert.equals(6, status.logs) - end) - - it("should not log when disabled", function() - logger.init({ - enable_logs = false - }) - - logger.info("This should not be logged") - - local status = logger.get_buffer_status() - assert.equals(0, status.logs) - end) - end) - - describe("structured logging", function() - before_each(function() - logger.init({ - enable_logs = true, - max_buffer_size = 10 - }) - end) - - it("should handle parameterized messages", function() - logger.info("User %s performed action %s", {"user123", "login"}) - - local status = logger.get_buffer_status() - assert.equals(1, status.logs) - end) - - it("should handle messages with attributes", function() - logger.info("Order processed", nil, { - order_id = "order123", - amount = 99.99, - success = true - }) - - local status = logger.get_buffer_status() - assert.equals(1, status.logs) - end) - - it("should handle both parameters and attributes", function() - logger.warn("Payment failed for user %s", {"user456"}, { - error_code = "DECLINED", - attempts = 3 - }) - - local status = logger.get_buffer_status() - assert.equals(1, status.logs) - end) - end) - - describe("buffer management", function() - it("should flush when buffer exceeds max size", function() - local flush_called = false - - logger.init({ - enable_logs = true, - max_buffer_size = 2 - }) - - -- Mock flush to detect when it's called - local original_flush = logger.flush - logger.flush = function() - flush_called = true - original_flush() - end - - logger.info("message 1") - assert.is_false(flush_called) - - logger.info("message 2") - assert.is_true(flush_called) - end) - - it("should track buffer status correctly", function() - logger.init({ - enable_logs = true, - max_buffer_size = 5 - }) - - local initial_status = logger.get_buffer_status() - assert.equals(0, initial_status.logs) - - logger.info("test message 1") - logger.info("test message 2") - - local status = logger.get_buffer_status() - assert.equals(2, status.logs) - assert.equals(5, status.max_size) - end) - - it("should clear buffer after flush", function() - logger.init({ - enable_logs = true - }) - - logger.info("test message") - local pre_flush_status = logger.get_buffer_status() - assert.equals(1, pre_flush_status.logs) - - logger.flush() - - local post_flush_status = logger.get_buffer_status() - assert.equals(0, post_flush_status.logs) - end) - end) - - describe("print hooking", function() - it("should hook print when enabled", function() - logger.init({ - enable_logs = true, - hook_print = true - }) - - local original_print_type = type(_G.print) - assert.equals("function", original_print_type) - - -- Print hooking changes print function - logger.hook_print() - - print("test message") - - local status = logger.get_buffer_status() - assert.equals(1, status.logs) - end) - - it("should unhook print correctly", function() - logger.init({ - enable_logs = true, - hook_print = true - }) - - logger.hook_print() - local hooked_print = _G.print - - logger.unhook_print() - - -- Should restore original print - assert.is_not.equals(hooked_print, _G.print) - end) - - it("should handle multiple print arguments", function() - logger.init({ - enable_logs = true, - hook_print = true - }) - - logger.hook_print() - - print("arg1", "arg2", 123, true) - - local status = logger.get_buffer_status() - assert.equals(1, status.logs) - end) - - it("should not create infinite loops", function() - logger.init({ - enable_logs = true, - hook_print = true - }) - - logger.hook_print() - - -- This should not cause infinite recursion - logger.info("This log might trigger internal print statements") - - -- If we get here without stack overflow, the recursion protection works - assert.is_true(true) - end) - end) - - describe("trace correlation", function() - it("should include trace_id when tracing is available", function() - -- This test would need mock tracing module - -- For now just verify it doesn't crash without tracing - logger.init({ - enable_logs = true - }) - - logger.info("test message") - - local status = logger.get_buffer_status() - assert.equals(1, status.logs) - end) - end) -end) - -describe("sentry.utils.envelope log support", function() - - describe("build_log_envelope", function() - it("should create valid log envelope", function() - local log_records = { - { - timestamp = 1234567890.5, - trace_id = "abc123", - level = "info", - body = "Test message", - attributes = { - ["test.key"] = {value = "test_value", type = "string"} - }, - severity_number = 9 - } - } - - local envelope_body = envelope.build_log_envelope(log_records) - - assert.is_string(envelope_body) - assert.is_not.equals("", envelope_body) - - -- Should contain log envelope structure - assert.is_truthy(envelope_body:find('"type":"log"')) - assert.is_truthy(envelope_body:find('"item_count":1')) - assert.is_truthy(envelope_body:find('items%.log')) - end) - - it("should handle multiple log records", function() - local log_records = { - { - timestamp = 1234567890.1, - trace_id = "trace1", - level = "info", - body = "Message 1", - attributes = {}, - severity_number = 9 - }, - { - timestamp = 1234567890.2, - trace_id = "trace2", - level = "error", - body = "Message 2", - attributes = {}, - severity_number = 17 - } - } - - local envelope_body = envelope.build_log_envelope(log_records) - - assert.is_string(envelope_body) - assert.is_truthy(envelope_body:find('"item_count":2')) - end) - - it("should return empty string for empty log array", function() - local envelope_body = envelope.build_log_envelope({}) - assert.equals("", envelope_body) - end) - - it("should return empty string for nil input", function() - local envelope_body = envelope.build_log_envelope(nil) - assert.equals("", envelope_body) - end) - - it("should include proper envelope structure", function() - local log_records = { - { - timestamp = 1234567890.5, - trace_id = "abc123", - level = "warn", - body = "Warning message", - attributes = { - ["severity"] = {value = "medium", type = "string"}, - ["count"] = {value = 42, type = "integer"} - }, - severity_number = 13 - } - } - - local envelope_body = envelope.build_log_envelope(log_records) - - -- Split envelope into lines - local lines = {} - for line in envelope_body:gmatch("[^\n]+") do - table.insert(lines, line) - end - - -- Should have 3 lines: header, item header, payload - assert.equals(3, #lines) - - -- Parse header - local header = json.decode(lines[1]) - assert.is_string(header.sent_at) - - -- Parse item header - local item_header = json.decode(lines[2]) - assert.equals("log", item_header.type) - assert.equals(1, item_header.item_count) - assert.equals("application/vnd.sentry.items.log+json", item_header.content_type) - - -- Parse payload - local payload = json.decode(lines[3]) - assert.is_table(payload.items) - assert.equals(1, #payload.items) - - local log_item = payload.items[1] - assert.equals("abc123", log_item.trace_id) - assert.equals("warn", log_item.level) - assert.equals("Warning message", log_item.body) - assert.equals(13, log_item.severity_number) - assert.is_table(log_item.attributes) - end) - end) -end) \ No newline at end of file diff --git a/spec/os_detection_spec.lua b/spec/os_detection_spec.lua deleted file mode 100644 index d5dbb9a..0000000 --- a/spec/os_detection_spec.lua +++ /dev/null @@ -1,168 +0,0 @@ -describe("OS Detection", function() - local os_utils - local original_detectors - - before_each(function() - -- Load the OS utils module - os_utils = require("sentry.utils.os") - - -- Save original detectors and clear them for testing - original_detectors = {} - for i, detector in ipairs(os_utils.detectors or {}) do - table.insert(original_detectors, detector) - end - - -- Clear detectors for clean test state - if os_utils.detectors then - for i = #os_utils.detectors, 1, -1 do - table.remove(os_utils.detectors, i) - end - end - end) - - after_each(function() - -- Restore original detectors - if os_utils.detectors then - for i = #os_utils.detectors, 1, -1 do - table.remove(os_utils.detectors, i) - end - for _, detector in ipairs(original_detectors) do - table.insert(os_utils.detectors, detector) - end - end - end) - - describe("detector registration", function() - it("should register a new detector", function() - local test_detector = { - detect = function() - return {name = "TestOS", version = "1.0"} - end - } - - os_utils.register_detector(test_detector) - - -- Verify detector was registered - local os_info = os_utils.get_os_info() - assert.are.equal("TestOS", os_info.name) - assert.are.equal("1.0", os_info.version) - end) - - it("should try detectors in registration order", function() - local first_detector = { - detect = function() - return {name = "FirstOS", version = "1.0"} - end - } - local second_detector = { - detect = function() - return {name = "SecondOS", version = "2.0"} - end - } - - os_utils.register_detector(first_detector) - os_utils.register_detector(second_detector) - - -- Should return result from first successful detector - local os_info = os_utils.get_os_info() - assert.are.equal("FirstOS", os_info.name) - end) - end) - - describe("nil version handling", function() - it("should handle detector returning nil version", function() - local detector_with_nil = { - detect = function() - return {name = "TestOS", version = nil} - end - } - - os_utils.register_detector(detector_with_nil) - - local os_info = os_utils.get_os_info() - assert.are.equal("TestOS", os_info.name) - assert.is_nil(os_info.version) - end) - - it("should handle detector returning no version field", function() - local detector_no_version = { - detect = function() - return {name = "TestOS"} - end - } - - os_utils.register_detector(detector_no_version) - - local os_info = os_utils.get_os_info() - assert.are.equal("TestOS", os_info.name) - assert.is_nil(os_info.version) - end) - end) - - describe("failed detection", function() - it("should return nil when no detectors are registered", function() - -- No detectors registered - local os_info = os_utils.get_os_info() - assert.is_nil(os_info) - end) - - it("should return nil when all detectors fail", function() - local failing_detector = { - detect = function() - return nil - end - } - - os_utils.register_detector(failing_detector) - - local os_info = os_utils.get_os_info() - assert.is_nil(os_info) - end) - - it("should handle detector that throws error", function() - local error_detector = { - detect = function() - error("Detection failed!") - end - } - local working_detector = { - detect = function() - return {name = "WorkingOS", version = "1.0"} - end - } - - os_utils.register_detector(error_detector) - os_utils.register_detector(working_detector) - - -- Should skip the error detector and use the working one - local os_info = os_utils.get_os_info() - assert.are.equal("WorkingOS", os_info.name) - end) - end) - - describe("detector interface", function() - it("should require detect function", function() - local invalid_detector = { - -- Missing detect function - } - - -- Register detector without error - os_utils.register_detector(invalid_detector) - - -- But it should fail when trying to use it - local os_info = os_utils.get_os_info() - assert.is_nil(os_info) - end) - - it("should handle detector with invalid detect function", function() - local invalid_detector = { - detect = "not a function" - } - - os_utils.register_detector(invalid_detector) - - local os_info = os_utils.get_os_info() - assert.is_nil(os_info) - end) - end) -end) \ No newline at end of file diff --git a/spec/performance_tracing_integration_spec.lua b/spec/performance_tracing_integration_spec.lua deleted file mode 100644 index 929d0dc..0000000 --- a/spec/performance_tracing_integration_spec.lua +++ /dev/null @@ -1,326 +0,0 @@ --- Integration tests for performance module and tracing propagation --- These tests would have caught the distributed tracing bug - -local performance = require("sentry.performance") -local propagation = require("sentry.tracing.propagation") -local tracing = require("sentry.tracing") - -describe("performance module tracing integration", function() - before_each(function() - -- Clear any existing context and transactions - propagation.clear_context() - end) - - after_each(function() - -- Clean up after each test - propagation.clear_context() - end) - - describe("new transaction creation", function() - it("should create new trace when no propagation context exists", function() - -- No existing trace context - assert.is_nil(propagation.get_current_context()) - - local transaction = performance.start_transaction("test_transaction", "task") - - assert.is_not_nil(transaction) - assert.is_not_nil(transaction.trace_id) - assert.is_not_nil(transaction.span_id) - assert.is_nil(transaction.parent_span_id) -- Root transaction - - -- Should update propagation context - local context = propagation.get_current_context() - assert.is_not_nil(context) - assert.are.equal(transaction.trace_id, context.trace_id) - assert.are.equal(transaction.span_id, context.span_id) - - transaction:finish("ok") - end) - - it("should continue existing trace when propagation context exists", function() - -- Set up existing trace context (simulating incoming request) - local existing_context = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000000", - parent_span_id = nil, - sampled = true, - baggage = {}, - dynamic_sampling_context = {} - } - propagation.set_current_context(existing_context) - - -- Start transaction - should continue existing trace - local transaction = performance.start_transaction("continued_transaction", "http.server") - - assert.is_not_nil(transaction) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", transaction.trace_id) -- Same trace ID - assert.are.equal("1000000000000000", transaction.parent_span_id) -- Previous span becomes parent - assert.are.not_equal("1000000000000000", transaction.span_id) -- New span ID for transaction - - -- Should update propagation context with new span - local updated_context = propagation.get_current_context() - assert.are.equal(transaction.trace_id, updated_context.trace_id) - assert.are.equal(transaction.span_id, updated_context.span_id) - assert.are.equal(transaction.parent_span_id, updated_context.parent_span_id) - - transaction:finish("ok") - end) - - it("should generate headers for outgoing requests during transaction", function() - local transaction = performance.start_transaction("client_transaction", "http.client") - - -- Should be able to get trace headers for outgoing requests - local headers = tracing.get_request_headers("http://api.example.com") - - assert.is_not_nil(headers) - assert.is_not_nil(headers["sentry-trace"]) - assert.is_not_nil(headers["sentry-trace"]:match(transaction.trace_id)) - assert.is_not_nil(headers["sentry-trace"]:match(transaction.span_id)) - - transaction:finish("ok") - end) - - it("should maintain trace context across spans", function() - local transaction = performance.start_transaction("parent_transaction", "task") - local original_trace_id = transaction.trace_id - - -- Start nested span - local span = transaction:start_span("db.query", "SELECT * FROM users") - - -- Trace ID should remain the same, span ID should change - local context_during_span = propagation.get_current_context() - assert.are.equal(original_trace_id, context_during_span.trace_id) - assert.are.equal(span.span_id, context_during_span.span_id) -- Current span is active - assert.are.equal(transaction.span_id, span.parent_span_id) -- Transaction is parent - - span:finish("ok") - - -- Context should revert to transaction after span finishes - local context_after_span = propagation.get_current_context() - assert.are.equal(original_trace_id, context_after_span.trace_id) - assert.are.equal(transaction.span_id, context_after_span.span_id) -- Back to transaction - - transaction:finish("ok") - end) - end) - - describe("distributed tracing workflow", function() - it("should simulate complete client-server distributed trace", function() - -- === CLIENT SIDE === - -- Start client transaction - local client_tx = performance.start_transaction("http_request", "http.client") - local original_trace_id = client_tx.trace_id - - -- Get headers for outgoing request - local outgoing_headers = tracing.get_request_headers("http://server.com/api") - assert.is_not_nil(outgoing_headers["sentry-trace"]) - - -- Parse the sentry-trace header to simulate what server receives - local sentry_headers = require("sentry.tracing.headers") - local trace_data = sentry_headers.parse_sentry_trace(outgoing_headers["sentry-trace"]) - assert.is_not_nil(trace_data) - assert.are.equal(original_trace_id, trace_data.trace_id) - assert.are.equal(client_tx.span_id, trace_data.span_id) - - -- === SERVER SIDE === - -- Simulate server receiving request and continuing trace - local incoming_headers = { - ["sentry-trace"] = outgoing_headers["sentry-trace"] - } - - -- Server continues trace from headers - local server_context = propagation.continue_trace_from_headers(incoming_headers) - assert.are.equal(original_trace_id, server_context.trace_id) -- Same trace - assert.are.equal(client_tx.span_id, server_context.parent_span_id) -- Client span becomes parent - - -- Server starts transaction - should continue the distributed trace - local server_tx = performance.start_transaction("handle_request", "http.server") - assert.are.equal(original_trace_id, server_tx.trace_id) -- Same distributed trace - -- The server transaction's parent should be the server's propagation context span_id - -- which was created from the client's span, so they're related but not identical - assert.is_not_nil(server_tx.parent_span_id) -- Connected through propagation context - - -- Server finishes its work - server_tx:finish("ok") - - -- === CLIENT SIDE CLEANUP === - -- Restore client context and finish client transaction - local client_context = { - trace_id = client_tx.trace_id, - span_id = client_tx.span_id, - parent_span_id = client_tx.parent_span_id, - sampled = true, - baggage = {}, - dynamic_sampling_context = {} - } - propagation.set_current_context(client_context) - client_tx:finish("ok") - - -- Note: In our current implementation, the server starts a new transaction - -- that continues the trace but creates a new transaction context. - -- The key is that the trace_id should be the same and parent_span_id should be correct. - assert.are.equal(client_tx.trace_id, server_tx.trace_id) - end) - - it("should handle multiple incoming trace formats", function() - -- Test W3C traceparent format - local w3c_headers = { - ["traceparent"] = "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01" - } - - propagation.continue_trace_from_headers(w3c_headers) - local tx1 = performance.start_transaction("w3c_continuation", "http.server") - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", tx1.trace_id) - tx1:finish("ok") - - -- Test sentry-trace format - local sentry_headers = { - ["sentry-trace"] = "75302ac48a024bde9a3b3734a82e36c8-2000000000000000-1" - } - - propagation.continue_trace_from_headers(sentry_headers) - local tx2 = performance.start_transaction("sentry_continuation", "http.server") - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", tx2.trace_id) - -- Note: The parent_span_id in our implementation will be the span_id from the - -- propagation context, not the original incoming span_id - assert.is_not_nil(tx2.parent_span_id) - tx2:finish("ok") - end) - end) - - describe("trace propagation targeting integration", function() - it("should respect trace propagation targets when getting headers", function() - -- Initialize tracing with specific targets - tracing.init({ - trace_propagation_targets = {"api.allowed.com"} - }) - - local transaction = performance.start_transaction("client_request", "http.client") - - -- Should propagate to allowed target - local allowed_headers = tracing.get_request_headers("https://api.allowed.com/endpoint") - assert.is_not_nil(allowed_headers["sentry-trace"]) - - -- Should not propagate to disallowed target - local blocked_headers = tracing.get_request_headers("https://api.blocked.com/endpoint") - -- The table should be empty but still a table - assert.are.equal("table", type(blocked_headers)) - assert.is_nil(blocked_headers["sentry-trace"]) - - transaction:finish("ok") - end) - - it("should propagate to all targets with wildcard", function() - tracing.init({ - trace_propagation_targets = {"*"} - }) - - local transaction = performance.start_transaction("client_request", "http.client") - - -- Should propagate to any target with wildcard - local headers1 = tracing.get_request_headers("https://api1.com/endpoint") - local headers2 = tracing.get_request_headers("https://api2.com/endpoint") - - assert.is_not_nil(headers1["sentry-trace"]) - assert.is_not_nil(headers2["sentry-trace"]) - assert.is_not_nil(headers1["sentry-trace"]:match(transaction.trace_id)) - assert.is_not_nil(headers2["sentry-trace"]:match(transaction.trace_id)) - - transaction:finish("ok") - end) - end) - - describe("error cases and edge conditions", function() - it("should handle malformed incoming trace headers gracefully", function() - local bad_headers = { - ["sentry-trace"] = "malformed-header-data" - } - - -- Should not crash, should create new trace - propagation.continue_trace_from_headers(bad_headers) - local transaction = performance.start_transaction("recovery_transaction", "http.server") - - assert.is_not_nil(transaction) - assert.is_not_nil(transaction.trace_id) - -- With malformed headers, it creates a new trace continuation context - assert.is_not_nil(transaction.parent_span_id) - - transaction:finish("ok") - end) - - it("should handle missing trace context during header generation", function() - -- Clear any context - propagation.clear_context() - - -- Should return empty headers when no context - local headers = tracing.get_request_headers("http://example.com") - assert.is_nil(headers["sentry-trace"]) - end) - - it("should maintain trace consistency with nested operations", function() - local transaction = performance.start_transaction("complex_operation", "task") - local trace_id = transaction.trace_id - - -- Multiple nested spans - local span1 = transaction:start_span("step1", "Process data") - assert.are.equal(trace_id, span1.trace_id) - - local span2 = transaction:start_span("step2", "Validate data") - assert.are.equal(trace_id, span2.trace_id) - assert.are.equal(span1.span_id, span2.parent_span_id) - - -- Headers should always contain current trace - local headers_during_nested = tracing.get_request_headers("http://api.com") - assert.is_not_nil(headers_during_nested["sentry-trace"]:match(trace_id)) - assert.is_not_nil(headers_during_nested["sentry-trace"]:match(span2.span_id)) - - span2:finish("ok") - span1:finish("ok") - transaction:finish("ok") - end) - end) - - describe("performance regression tests", function() - it("should handle rapid transaction creation/destruction", function() - -- This test would catch memory leaks or context corruption - for i = 1, 10 do - local tx = performance.start_transaction("rapid_tx_" .. i, "task") - local context = propagation.get_current_context() - - assert.is_not_nil(context) - assert.are.equal(tx.trace_id, context.trace_id) - - tx:finish("ok") - end - - -- Context should be clean after all transactions (or have the last context) - local final_context = propagation.get_current_context() - -- The context might still exist but should be consistent - assert.is_not_nil(final_context) - end) - - it("should handle concurrent-style trace context switching", function() - -- Simulate context switching (like coroutines/async) - local tx1 = performance.start_transaction("tx1", "task") - local context1 = propagation.get_current_context() - - -- Switch to different context - local tx2 = performance.start_transaction("tx2", "task") - local context2 = propagation.get_current_context() - - -- In our current implementation, the second transaction might continue from the first - -- This is actually good behavior for distributed tracing - -- assert.are.not_equal(context1.trace_id, context2.trace_id) - - -- Switch back to first context (simulating async resume) - propagation.set_current_context(context1) - local resumed_context = propagation.get_current_context() - assert.are.equal(context1.trace_id, resumed_context.trace_id) - - -- Clean up both - tx1:finish("ok") -- tx1 context - propagation.set_current_context(context2) - tx2:finish("ok") -- tx2 context - end) - end) -end) \ No newline at end of file diff --git a/spec/platform_detection_spec.lua b/spec/platform_detection_spec.lua deleted file mode 100644 index c8d319e..0000000 --- a/spec/platform_detection_spec.lua +++ /dev/null @@ -1,192 +0,0 @@ -describe("Platform Detection", function() - -- Note: These tests focus on the platform detection logic rather than - -- executing actual system commands, to ensure tests are portable and reliable - - describe("Desktop platform detection", function() - local desktop_platform - - before_each(function() - -- We can't easily test the actual standard platform detection since it relies on - -- system commands, but we can test the module structure - desktop_platform = require("sentry.platforms.standard.os_detection") - end) - - it("should have detect_os function", function() - assert.is_function(desktop_platform.detect_os) - end) - - it("should return nil or valid OSInfo", function() - local result = desktop_platform.detect_os() - - if result then - -- If detection succeeded, should have name - assert.is_string(result.name) - -- Version can be nil or string - assert.is_true(result.version == nil or type(result.version) == "string") - else - -- If detection failed, should be nil - assert.is_nil(result) - end - end) - end) - - describe("Roblox platform detection", function() - local roblox_platform - local original_G - - before_each(function() - roblox_platform = require("sentry.platforms.roblox.os_detection") - original_G = _G - end) - - after_each(function() - _G = original_G - end) - - it("should have detect_os function", function() - assert.is_function(roblox_platform.detect_os) - end) - - it("should return nil when not in Roblox environment", function() - -- Ensure we're not in Roblox environment - _G.game = nil - - local result = roblox_platform.detect_os() - assert.is_nil(result) - end) - - it("should detect Roblox when game and GetService exist", function() - -- Mock Roblox environment - _G.game = { - GetService = function() return {} end - } - - local result = roblox_platform.detect_os() - - assert.is_not_nil(result) - assert.are.equal("Roblox", result.name) - assert.is_nil(result.version) -- Should be nil, not empty string - end) - - it("should return nil when game exists but no GetService", function() - _G.game = {} -- No GetService method - - local result = roblox_platform.detect_os() - assert.is_nil(result) - end) - end) - - describe("LÖVE 2D platform detection", function() - local love2d_platform - local original_love - - before_each(function() - love2d_platform = require("sentry.platforms.love2d.os_detection") - original_love = _G.love - end) - - after_each(function() - _G.love = original_love - end) - - it("should have detect_os function", function() - assert.is_function(love2d_platform.detect_os) - end) - - it("should return nil when not in LÖVE environment", function() - _G.love = nil - - local result = love2d_platform.detect_os() - assert.is_nil(result) - end) - - it("should detect OS when LÖVE system is available", function() - _G.love = { - system = { - getOS = function() return "Windows" end - } - } - - local result = love2d_platform.detect_os() - - assert.is_not_nil(result) - assert.are.equal("Windows", result.name) - assert.is_nil(result.version) -- Should be nil, not empty string - end) - - it("should return nil when love.system.getOS returns nil", function() - _G.love = { - system = { - getOS = function() return nil end - } - } - - local result = love2d_platform.detect_os() - assert.is_nil(result) - end) - - it("should handle missing love.system", function() - _G.love = {} -- No system module - - local result = love2d_platform.detect_os() - assert.is_nil(result) - end) - end) - - describe("Nginx platform detection", function() - local nginx_platform - local original_ngx - - before_each(function() - nginx_platform = require("sentry.platforms.nginx.os_detection") - original_ngx = _G.ngx - end) - - after_each(function() - _G.ngx = original_ngx - end) - - it("should have detect_os function", function() - assert.is_function(nginx_platform.detect_os) - end) - - it("should return nil when not in nginx environment", function() - _G.ngx = nil - - local result = nginx_platform.detect_os() - assert.is_nil(result) - end) - - -- Note: Testing nginx detection fully would require mocking io.popen - -- which is complex. We mainly test the environment detection logic. - end) - - describe("Platform loader", function() - it("should load platform modules without error", function() - -- This test ensures platform_loader can be required and executes without error - assert.has_no.errors(function() - require("sentry.platform_loader") - end) - end) - end) - - describe("Integration test", function() - it("should have at least one working detector after loading platforms", function() - -- Load platform loader to register detectors - require("sentry.platform_loader") - - local os_utils = require("sentry.utils.os") - local result = os_utils.get_os_info() - - -- Should detect at least the desktop platform on most systems - -- If this fails, it might be running in a very limited environment - if result then - assert.is_string(result.name) - -- Version can be nil - assert.is_true(result.version == nil or type(result.version) == "string") - end - -- Note: We don't assert result is not nil because the test might run - -- in an environment where no detectors work - end) - end) -end) \ No newline at end of file diff --git a/spec/platforms/love2d/conf.lua b/spec/platforms/love2d/conf.lua deleted file mode 100644 index ef17791..0000000 --- a/spec/platforms/love2d/conf.lua +++ /dev/null @@ -1,45 +0,0 @@ --- Minimal Love2D configuration for headless testing - -function love.conf(t) - t.identity = "sentry-love2d-tests" - t.version = "11.5" - t.console = false - - -- Minimal headless window configuration - -- Note: t.window = false doesn't work reliably across all Love2D versions - -- Instead use minimal window config that works in headless environments - t.window.title = "Sentry Love2D Tests (Headless)" - t.window.width = 1 - t.window.height = 1 - t.window.borderless = true - t.window.resizable = false - t.window.minwidth = 1 - t.window.minheight = 1 - t.window.fullscreen = false - t.window.fullscreentype = "desktop" - t.window.vsync = 0 - t.window.display = 1 - t.window.highdpi = false - t.window.x = nil - t.window.y = nil - - -- Disable all non-essential modules for headless testing - t.modules.audio = false - t.modules.data = false - t.modules.event = true - t.modules.font = false - t.modules.graphics = true -- Keep minimal graphics for headless compatibility - t.modules.image = false - t.modules.joystick = false - t.modules.keyboard = false - t.modules.math = false - t.modules.mouse = false - t.modules.physics = false - t.modules.sound = false - t.modules.system = true - t.modules.thread = true - t.modules.timer = true - t.modules.touch = false - t.modules.video = false - t.modules.window = true -- Keep window module for headless compatibility -end \ No newline at end of file diff --git a/spec/platforms/love2d/https_connectivity_spec.lua b/spec/platforms/love2d/https_connectivity_spec.lua deleted file mode 100644 index 5b435ed..0000000 --- a/spec/platforms/love2d/https_connectivity_spec.lua +++ /dev/null @@ -1,119 +0,0 @@ --- Love2D HTTPS connectivity and lua-https integration tests -describe("Love2D HTTPS connectivity", function() - local sentry - local http - - setup(function() - -- Mock Love2D environment - _G.love = { - filesystem = { - write = function(filename, data) - return true - end - } - } - - package.path = "build/?.lua;build/?/init.lua;" .. package.path - sentry = require("sentry") - http = require("sentry.utils.http") - end) - - teardown(function() - _G.love = nil - end) - - describe("HTTP client functionality", function() - it("should successfully make HTTPS requests to test endpoints", function() - local response = http.request({ - url = "https://httpbin.org/get", - method = "GET", - timeout = 10 - }) - - assert.is_true(response.success) - assert.equals(200, response.status) - end) - - it("should handle HTTPS request failures gracefully", function() - local response = http.request({ - url = "https://nonexistent-domain-for-testing.com/endpoint", - method = "GET", - timeout = 5 - }) - - assert.is_false(response.success) - assert.is_not_nil(response.error) - end) - end) - - describe("Sentry integration", function() - it("should initialize successfully in Love2D environment", function() - local result = sentry.init({ - dsn = "https://testkey@test.ingest.sentry.io/123456", - environment = "love2d-test", - debug = true - }) - - assert.is_not_nil(sentry._client) - end) - - it("should capture messages with proper error handling", function() - sentry.init({ - dsn = "https://testkey@test.ingest.sentry.io/123456", - environment = "love2d-spec-test" - }) - - -- Should not throw errors even with invalid DSN in test - local ok = pcall(function() - sentry.capture_message("Test message from Love2D spec", "info") - end) - - assert.is_true(ok) - end) - - it("should capture exceptions with stack traces", function() - sentry.init({ - dsn = "https://testkey@test.ingest.sentry.io/123456", - environment = "love2d-spec-test" - }) - - local function level3() - error("Test error for stack trace validation") - end - - local function level2() - level3() - end - - local function level1() - level2() - end - - -- Should capture exception without crashing - local ok, err = pcall(level1) - assert.is_false(ok) - - local capture_ok = pcall(function() - sentry.capture_exception(err) - end) - - assert.is_true(capture_ok) - end) - end) - - describe("Love2D transport selection", function() - it("should detect Love2D environment properly", function() - local love2d_transport = require("sentry.platforms.love2d.transport") - - -- Should detect Love2D environment when love global exists - assert.is_true(love2d_transport.is_love2d_available()) - - -- Should be able to create transport - local transport = love2d_transport.create_love2d_transport({ - dsn = "https://testkey@test.ingest.sentry.io/123456" - }) - - assert.is_not_nil(transport) - end) - end) -end) \ No newline at end of file diff --git a/spec/platforms/love2d/love2d_spec.lua b/spec/platforms/love2d/love2d_spec.lua deleted file mode 100644 index 849d83c..0000000 --- a/spec/platforms/love2d/love2d_spec.lua +++ /dev/null @@ -1,291 +0,0 @@ --- Love2D platform unit tests --- These run with busted but mock the Love2D environment - -describe("sentry.platforms.love2d", function() - - -- Mock Love2D environment - local original_love - - before_each(function() - original_love = _G.love - - -- Mock Love2D global - _G.love = { - system = { - getOS = function() return "macOS" end - }, - getVersion = function() return 11, 5, 0, "Mysterious Mysteries" end, - graphics = { - getDimensions = function() return 800, 600 end - }, - thread = { - newThread = function(code) - return { - start = function() end, - wait = function() end - } - end, - getChannel = function(name) - return { - push = function() end, - pop = function() return nil end - } - end - }, - filesystem = { - append = function() return true end - }, - timer = { - sleep = function() end, - getTime = function() return 0 end - } - } - end) - - after_each(function() - _G.love = original_love - end) - - describe("transport", function() - local transport_module - - before_each(function() - package.loaded["sentry.platforms.love2d.transport"] = nil - transport_module = require("sentry.platforms.love2d.transport") - end) - - it("should detect Love2D availability", function() - assert.is_true(transport_module.is_love2d_available()) - end) - - it("should create Love2D transport", function() - local config = { - dsn = "https://key@host/123" - } - - local transport = transport_module.create_love2d_transport(config) - assert.is_not_nil(transport) - assert.is_function(transport.send) - end) - - it("should queue events for async sending", function() - local config = { - dsn = "https://key@host/123" - } - - local transport = transport_module.create_love2d_transport(config) - local success, message = transport:send({event_id = "test123"}) - - assert.is_true(success) - assert.matches("queued", message) - end) - - it("should queue envelopes for async sending", function() - local config = { - dsn = "https://key@host/123" - } - - local transport = transport_module.create_love2d_transport(config) - local success, message = transport:send_envelope("test envelope data") - - assert.is_true(success) - assert.matches("queued", message) - end) - - it("should handle flush operation", function() - local config = { - dsn = "https://key@host/123" - } - - local transport = transport_module.create_love2d_transport(config) - - -- Should not error - assert.has_no.errors(function() - transport:flush() - end) - end) - - it("should queue events but not process them without lua-https", function() - local config = { - dsn = "https://key@host/123" - } - - local transport = transport_module.create_love2d_transport(config) - - -- Queue some events - local test_event = {event_id = "test123", message = "test message"} - transport:send(test_event) - - -- Verify event was queued - assert.equals(1, #transport.event_queue) - - -- Flush should return early without lua-https, leaving queues intact - transport:flush() - - -- Queue should still have items since lua-https is not available - assert.equals(1, #transport.event_queue) - end) - - it("should queue envelopes but not process them without lua-https", function() - local config = { - dsn = "https://key@host/123" - } - - local transport = transport_module.create_love2d_transport(config) - - -- Queue some envelopes - local test_envelope = "test envelope data" - transport:send_envelope(test_envelope) - - -- Verify envelope was queued - assert.equals(1, #transport.envelope_queue) - - -- Flush should return early without lua-https, leaving queues intact - transport:flush() - - -- Queue should still have items since lua-https is not available - assert.equals(1, #transport.envelope_queue) - end) - - it("should manage both event and envelope queues independently", function() - local config = { - dsn = "https://key@host/123" - } - - local transport = transport_module.create_love2d_transport(config) - - -- Queue multiple items - transport:send({event_id = "event1"}) - transport:send({event_id = "event2"}) - transport:send_envelope("envelope1") - transport:send_envelope("envelope2") - - -- Verify items were queued - assert.equals(2, #transport.event_queue) - assert.equals(2, #transport.envelope_queue) - - -- Without lua-https, flush returns early and queues remain - transport:flush() - - -- Both queues should still have items - assert.equals(2, #transport.event_queue) - assert.equals(2, #transport.envelope_queue) - end) - - it("should handle close operation", function() - local config = { - dsn = "https://key@host/123" - } - - local transport = transport_module.create_love2d_transport(config) - - -- Should not error - assert.has_no.errors(function() - transport:close() - end) - end) - end) - - describe("os_detection", function() - local os_detection - - before_each(function() - package.loaded["sentry.platforms.love2d.os_detection"] = nil - os_detection = require("sentry.platforms.love2d.os_detection") - end) - - it("should detect OS from Love2D", function() - local os_info = os_detection.detect_os() - assert.is_not_nil(os_info) - assert.equals("macOS", os_info.name) - assert.is_nil(os_info.version) -- Love2D doesn't provide version - end) - end) - - describe("context", function() - local context_module - - before_each(function() - package.loaded["sentry.platforms.love2d.context"] = nil - context_module = require("sentry.platforms.love2d.context") - end) - - it("should get Love2D context information", function() - local context = context_module.get_love2d_context() - - assert.is_table(context) - assert.equals("11.5.0.Mysterious Mysteries", context.love_version) - assert.equals("macOS", context.os) - assert.is_table(context.screen) - assert.equals(800, context.screen.width) - assert.equals(600, context.screen.height) - end) - - it("should handle missing Love2D gracefully", function() - _G.love = nil - - local context = context_module.get_love2d_context() - assert.is_table(context) - -- Should be empty but not error - end) - end) - - describe("integration", function() - it("should work with main Sentry SDK", function() - local sentry = require("sentry") - - -- Initialize with Love2D transport - sentry.init({ - dsn = "https://key@host/123", - environment = "test" - }) - - assert.is_not_nil(sentry._client) - assert.is_not_nil(sentry._client.transport) - - -- Should be able to capture events - local event_id = sentry.capture_message("Test message") - assert.is_string(event_id) - assert.not_equals("", event_id) - end) - - it("should work with logger module", function() - local sentry = require("sentry") - local logger = require("sentry.logger") - - sentry.init({ - dsn = "https://key@host/123" - }) - - logger.init({ - enable_logs = true, - max_buffer_size = 1 - }) - - -- Should not error - assert.has_no.errors(function() - logger.info("Test log from Love2D") - logger.flush() - end) - end) - end) - - describe("without Love2D", function() - before_each(function() - _G.love = nil - end) - - it("should not be available", function() - package.loaded["sentry.platforms.love2d.transport"] = nil - local transport_module = require("sentry.platforms.love2d.transport") - assert.is_false(transport_module.is_love2d_available()) - end) - - it("should return empty OS detection", function() - package.loaded["sentry.platforms.love2d.os_detection"] = nil - local os_detection = require("sentry.platforms.love2d.os_detection") - local os_info = os_detection.detect_os() - assert.is_nil(os_info) - end) - end) -end) \ No newline at end of file diff --git a/spec/platforms/love2d/main.lua b/spec/platforms/love2d/main.lua deleted file mode 100644 index de83d89..0000000 --- a/spec/platforms/love2d/main.lua +++ /dev/null @@ -1,373 +0,0 @@ --- Headless Love2D test runner for Sentry SDK --- This runs actual tests in the Love2D runtime environment - --- Add build path for modules -package.path = "../../../build/?.lua;../../../build/?/init.lua;" .. package.path - -local test_results = { - passed = 0, - failed = 0, - tests = {}, - start_time = 0 -} - -local function log(message) - print("[LOVE2D-TEST] " .. message) -end - -local function assert_equal(actual, expected, message) - if actual == expected then - test_results.passed = test_results.passed + 1 - log("✓ " .. (message or "assertion passed")) - table.insert(test_results.tests, {status = "PASS", message = message or "assertion"}) - else - test_results.failed = test_results.failed + 1 - local error_msg = string.format("Expected '%s', got '%s' - %s", tostring(expected), tostring(actual), message or "assertion failed") - log("✗ " .. error_msg) - table.insert(test_results.tests, {status = "FAIL", message = error_msg}) - end -end - -local function assert_true(condition, message) - assert_equal(condition, true, message) -end - -local function assert_false(condition, message) - assert_equal(condition, false, message) -end - -local function assert_not_equal(actual, expected, message) - if actual == expected then - test_results.failed = test_results.failed + 1 - local error_msg = string.format("Expected '%s' to NOT equal '%s' - %s", tostring(actual), tostring(expected), message or "assertion failed") - log("✗ " .. error_msg) - table.insert(test_results.tests, {status = "FAIL", message = error_msg}) - else - test_results.passed = test_results.passed + 1 - log("✓ " .. (message or "values are not equal")) - table.insert(test_results.tests, {status = "PASS", message = message or "values are not equal"}) - end -end - -local function assert_not_nil(value, message) - if value ~= nil then - test_results.passed = test_results.passed + 1 - log("✓ " .. (message or "value is not nil")) - table.insert(test_results.tests, {status = "PASS", message = message or "not nil"}) - else - test_results.failed = test_results.failed + 1 - local error_msg = "Expected non-nil value - " .. (message or "nil check failed") - log("✗ " .. error_msg) - table.insert(test_results.tests, {status = "FAIL", message = error_msg}) - end -end - -local function run_test(name, test_func) - log("Running test: " .. name) - local success, err = pcall(test_func) - if not success then - test_results.failed = test_results.failed + 1 - local error_msg = "Test crashed: " .. tostring(err) - log("✗ " .. error_msg) - table.insert(test_results.tests, {status = "FAIL", message = error_msg}) - end -end - -local function test_love2d_environment() - assert_not_nil(_G.love, "Love2D global should exist") - assert_not_nil(love.system, "love.system should exist") - assert_not_nil(love.thread, "love.thread should exist") - assert_not_nil(love.timer, "love.timer should exist") - - -- Test OS detection - local os_name = love.system.getOS() - assert_not_nil(os_name, "OS name should be detected") - log("Detected OS: " .. tostring(os_name)) - - -- Test Love2D version - local major, minor, revision, codename = love.getVersion() - assert_not_nil(major, "Love2D major version should exist") - local version_string = string.format("%d.%d.%d (%s)", major, minor, revision, codename) - log("Love2D version: " .. version_string) -end - -local function test_module_loading() - -- Test Sentry core module - local sentry = require("sentry") - assert_not_nil(sentry, "Sentry module should load") - assert_not_nil(sentry.init, "Sentry.init should exist") - - -- Test logger module - local logger = require("sentry.logger") - assert_not_nil(logger, "Logger module should load") - assert_not_nil(logger.init, "Logger.init should exist") - - -- Test Love2D specific modules - local transport = require("sentry.platforms.love2d.transport") - assert_not_nil(transport, "Love2D transport should load") - assert_true(transport.is_love2d_available(), "Love2D transport should be available in Love2D runtime") - - local os_detection = require("sentry.platforms.love2d.os_detection") - assert_not_nil(os_detection, "Love2D OS detection should load") - - local context = require("sentry.platforms.love2d.context") - assert_not_nil(context, "Love2D context should load") -end - -local function test_sentry_initialization() - local sentry = require("sentry") - - -- Initialize Sentry - sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - environment = "love2d-test", - release = "love2d-test@1.0.0", - debug = true - }) - - assert_not_nil(sentry._client, "Sentry client should be initialized") - assert_not_nil(sentry._client.transport, "Sentry transport should be initialized") - - -- Verify Love2D transport is selected - local transport_name = "unknown" - if sentry._client.transport then - -- Check if it's the Love2D transport by testing clean API methods - if sentry.flush and sentry.close then - transport_name = "love2d" - end - end - - assert_equal(transport_name, "love2d", "Love2D transport should be selected") - log("Transport selected: " .. transport_name) -end - -local function test_logger_functionality() - local logger = require("sentry.logger") - - logger.init({ - enable_logs = true, - max_buffer_size = 2, - flush_timeout = 1.0, - hook_print = false -- Disable to avoid interference with test output - }) - - -- Test basic logging - logger.info("Test log message from Love2D") - logger.error("Test error message from Love2D") - - -- Get buffer status - local status = logger.get_buffer_status() - assert_not_nil(status, "Buffer status should be available") - assert_true(status.logs >= 0, "Buffer should have non-negative log count") - - -- Test flush - logger.flush() - log("Logger test completed successfully") -end - -local function test_error_capture() - local sentry = require("sentry") - local logger = require("sentry.logger") - - -- Add breadcrumb - sentry.add_breadcrumb({ - message = "Love2D test breadcrumb", - category = "test", - level = "info" - }) - - -- Test exception capture - local function error_handler(err) - sentry.capture_exception({ - type = "Love2DTestError", - message = tostring(err) - }) - return err - end - - -- Trigger error in controlled way - local success, result = xpcall(function() - error("Love2D test error for stack trace verification") - end, error_handler) - - assert_false(success, "Error should have been caught") - assert_not_nil(result, "Error result should exist") - - log("Error capture test completed") -end - -local function test_transport_functionality() - local sentry = require("sentry") - - -- Test transport methods exist - if sentry._client and sentry._client.transport then - local transport = sentry._client.transport - - assert_not_nil(transport.flush, "Transport should have flush method") - - -- Test flush (should not error) - local success, err = pcall(function() - transport:flush() - end) - assert_true(success, "Transport flush should succeed: " .. tostring(err or "")) - - log("Transport functionality test completed") - end -end - -local function test_error_handler_integration() - log("Testing Love2D error handler integration...") - - -- Test loading integration module - local ok, integration_module = pcall(require, "sentry.platforms.love2d.integration") - assert_true(ok, "Should be able to load Love2D integration module") - assert_not_nil(integration_module.setup_love2d_integration, "Integration module should export setup function") - - -- Test creating integration instance - local integration = integration_module.setup_love2d_integration() - assert_not_nil(integration, "Integration should be created successfully") - assert_true(type(integration.install_error_handler) == "function", "Integration should have install_error_handler method") - assert_true(type(integration.uninstall_error_handler) == "function", "Integration should have uninstall_error_handler method") - - -- Store original error handler - local original_handler = love.errorhandler - - -- Test installing error handler with updated API - integration:install_error_handler({ - capture_exception = function(self, exception_data, level) - log("Mock capture_exception called with type: " .. (exception_data.type or "unknown") .. " level: " .. (level or "unknown")) - return "test-event-id" - end, - transport = { - flush = function(self) - log("Mock transport flush called") - end - } - }) - - assert_not_equal(love.errorhandler, original_handler, "Error handler should be replaced") - - -- Test the error handler by triggering a fatal error (it should be captured and re-thrown) - local error_captured = false - local flush_called = false - - integration:uninstall_error_handler() - integration:install_error_handler({ - options = { - environment = "test", - debug = true - }, - scope = { - apply_to_event = function(self, event) - log("✓ Scope applied to fatal error event") - return event - end - }, - transport = { - send = function(self, event) - log("✓ Fatal error captured with mechanism: " .. (event.exception and event.exception.values and event.exception.values[1] and event.exception.values[1].mechanism and event.exception.values[1].mechanism.type or "unknown")) - error_captured = true - if event.exception and event.exception.values and event.exception.values[1] then - local exception = event.exception.values[1] - assert_equal(exception.type, "RuntimeError", "Exception type should be RuntimeError") - assert_equal(exception.mechanism.type, "love.errorhandler", "Mechanism type should be love.errorhandler") - assert_equal(exception.mechanism.handled, false, "Mechanism handled should be false") - end - return true, nil - end, - flush = function(self) - log("✓ Transport flush called for fatal error") - flush_called = true - end - } - }) - - -- Try to trigger the error handler (should be captured then re-thrown) - local caught_error = nil - local success = pcall(function() - love.errorhandler("Fatal Love2D error triggered by test - Testing love.errorhandler integration!") - end) - - -- The error handler should have captured the error and re-thrown it - assert_true(error_captured, "Fatal error should have been captured by integration") - assert_true(flush_called, "Transport flush should have been called") - - -- Test uninstalling error handler - integration:uninstall_error_handler() - assert_equal(love.errorhandler, original_handler, "Error handler should be restored") - - log("Error handler integration test completed") -end - -function love.load() - log("Starting Love2D Sentry SDK tests...") - test_results.start_time = love.timer.getTime() - - -- Run all tests - run_test("Love2D Environment", test_love2d_environment) - run_test("Module Loading", test_module_loading) - run_test("Sentry Initialization", test_sentry_initialization) - run_test("Logger Functionality", test_logger_functionality) - run_test("Error Capture", test_error_capture) - run_test("Transport Functionality", test_transport_functionality) - run_test("Error Handler Integration", test_error_handler_integration) - - -- Wait a moment for async operations - love.timer.sleep(1) -end - -local function print_results() - local elapsed = love.timer.getTime() - test_results.start_time - - log("=== Test Results ===") - log(string.format("Tests run: %d", test_results.passed + test_results.failed)) - log(string.format("Passed: %d", test_results.passed)) - log(string.format("Failed: %d", test_results.failed)) - log(string.format("Time: %.2f seconds", elapsed)) - log("") - - -- Print individual test results - for _, test in ipairs(test_results.tests) do - log(string.format("[%s] %s", test.status, test.message)) - end - - log("") - - if test_results.failed == 0 then - log("All tests passed! 🎉") - love.event.quit(0) - else - log("Some tests failed! ❌") - love.event.quit(1) - end -end - --- Simple update loop to handle exit -local exit_timer = 0 -function love.update(dt) - exit_timer = exit_timer + dt - - -- Flush transports periodically - if love.timer.getTime() - test_results.start_time > 0.5 then - local sentry = package.loaded.sentry - if sentry and sentry.flush then - sentry.flush() - end - - local logger = package.loaded["sentry.logger"] - if logger and logger.flush then - logger.flush() - end - end - - -- Exit after enough time for async operations - if exit_timer > 3 then - print_results() - end -end - --- Minimal draw function for headless mode (graphics disabled) -function love.draw() - -- No graphics in headless mode - -- Test progress is printed to console instead -end \ No newline at end of file diff --git a/spec/platforms/lua/http_integration_spec.lua b/spec/platforms/lua/http_integration_spec.lua deleted file mode 100644 index d560d6e..0000000 --- a/spec/platforms/lua/http_integration_spec.lua +++ /dev/null @@ -1,431 +0,0 @@ -local tracing = require("sentry.tracing") -local http_client = require("platforms.lua.http_client") -local http_server = require("platforms.lua.http_server") - -describe("HTTP Integration Tests", function() - before_each(function() - tracing.clear() - end) - - after_each(function() - tracing.clear() - end) - - describe("HTTP Client Integration", function() - describe("wrap_generic_client", function() - it("should add trace headers to outgoing requests", function() - tracing.start_trace() - - local mock_client = function(url, options) - return { - url = url, - headers_sent = options.headers or {} - } - end - - local wrapped_client = http_client.wrap_generic_client(mock_client) - local result = wrapped_client("https://example.com", {method = "GET"}) - - assert.equal("https://example.com", result.url) - assert.is_not_nil(result.headers_sent["sentry-trace"]) - end) - - it("should not add headers when tracing is inactive", function() - tracing.clear() - - local mock_client = function(url, options) - return {headers_sent = options.headers or {}} - end - - local wrapped_client = http_client.wrap_generic_client(mock_client) - local result = wrapped_client("https://example.com") - - assert.is_nil(result.headers_sent["sentry-trace"]) - end) - end) - - describe("luasocket integration", function() - it("should wrap LuaSocket http module", function() - local mock_http = { - request = function(url_or_options, body) - if type(url_or_options) == "string" then - return "response", 200, {} - else - return "response", 200, url_or_options.headers or {} - end - end - } - - tracing.start_trace() - local wrapped_http = http_client.luasocket.wrap_http_module(mock_http) - - -- Test string URL format - local body, status, headers = wrapped_http.request("https://example.com") - assert.equal("response", body) - assert.equal(200, status) - - -- Test options table format - local body2, status2, headers2 = wrapped_http.request({ - url = "https://example.com", - headers = {} - }) - assert.equal("response", body2) - assert.is_not_nil(headers2["sentry-trace"]) - end) - - it("should error for invalid http module", function() - assert.has_error(function() - http_client.luasocket.wrap_http_module({}) - end) - end) - end) - - describe("lua-http integration", function() - it("should wrap lua-http request object", function() - local headers_added = {} - - local mock_request = { - get_headers_as_sequence = function() return {} end, - get_uri = function() return "https://example.com" end, - append_header = function(self, key, value) - headers_added[key] = value - end, - go = function(self) - return "response" - end - } - - tracing.start_trace() - local wrapped_request = http_client.lua_http.wrap_request(mock_request) - local result = wrapped_request:go() - - assert.equal("response", result) - assert.is_not_nil(headers_added["sentry-trace"]) - end) - - it("should error for invalid request object", function() - assert.has_error(function() - http_client.lua_http.wrap_request({}) - end, "Invalid lua-http request object") - end) - end) - - describe("create_traced_request", function() - it("should create traced request function", function() - local mock_request = function(url, options) - return { - url = url, - headers = options and options.headers or {} - } - end - - tracing.start_trace() - local traced_request = http_client.create_traced_request(mock_request) - local result = traced_request("https://example.com", {method = "GET"}) - - assert.equal("https://example.com", result.url) - assert.is_not_nil(result.headers["sentry-trace"]) - end) - - it("should error for non-function input", function() - assert.has_error(function() - http_client.create_traced_request("not a function") - end, "make_request must be a function") - end) - end) - - describe("create_middleware", function() - it("should create middleware that adds trace headers", function() - tracing.start_trace() - - local original_function = function(url, options) - return { - url = url, - headers = options and options.headers or {} - } - end - - local middleware = http_client.create_middleware(original_function) - local result = middleware("https://example.com", {headers = {}}) - - assert.equal("https://example.com", result.url) - assert.is_not_nil(result.headers["sentry-trace"]) - end) - - it("should use custom URL extractor", function() - tracing.start_trace() - - local original_function = function(config) - return { - url = config.target, - headers = config.headers or {} - } - end - - local extract_url = function(args) - return args[1].target - end - - local middleware = http_client.create_middleware(original_function, extract_url) - local result = middleware({ - target = "https://example.com", - headers = {} - }) - - assert.is_not_nil(result.headers["sentry-trace"]) - end) - - it("should error for non-function client", function() - assert.has_error(function() - http_client.create_middleware("not a function") - end, "client_function must be a function") - end) - end) - end) - - describe("HTTP Server Integration", function() - describe("wrap_handler", function() - it("should continue trace from request headers", function() - local trace_info_captured = nil - - local handler = function(request, response) - trace_info_captured = tracing.get_current_trace_info() - return "handled" - end - - local wrapped_handler = http_server.wrap_handler(handler) - - local mock_request = { - headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - } - - local result = wrapped_handler(mock_request, {}) - - assert.equal("handled", result) - assert.is_not_nil(trace_info_captured) - assert.equal("1234567890abcdef1234567890abcdef", trace_info_captured.trace_id) - end) - - it("should start new trace when no headers present", function() - local trace_info_captured = nil - - local handler = function(request, response) - trace_info_captured = tracing.get_current_trace_info() - end - - local wrapped_handler = http_server.wrap_handler(handler) - wrapped_handler({headers = {}}, {}) - - assert.is_not_nil(trace_info_captured) - assert.is_not_nil(trace_info_captured.trace_id) - assert.is_nil(trace_info_captured.parent_span_id) -- New trace - end) - - it("should use custom header extractor", function() - local trace_info_captured = nil - - local handler = function(request, response) - trace_info_captured = tracing.get_current_trace_info() - end - - local extract_headers = function(request) - return request.custom_headers or {} - end - - local wrapped_handler = http_server.wrap_handler(handler, extract_headers) - - local mock_request = { - custom_headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - } - - wrapped_handler(mock_request, {}) - - assert.equal("1234567890abcdef1234567890abcdef", trace_info_captured.trace_id) - end) - - it("should error for non-function handler", function() - assert.has_error(function() - http_server.wrap_handler("not a function") - end, "handler must be a function") - end) - end) - - describe("create_generic_middleware", function() - it("should create middleware that continues traces", function() - local trace_info_captured = nil - - local next_func = function() - trace_info_captured = tracing.get_current_trace_info() - return "next called" - end - - local extract_headers = function(request) - return request.headers - end - - local middleware = http_server.create_generic_middleware(extract_headers) - - local mock_request = { - headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - } - - local result = middleware(mock_request, {}, next_func) - - assert.equal("next called", result) - assert.equal("1234567890abcdef1234567890abcdef", trace_info_captured.trace_id) - end) - - it("should normalize header keys to lowercase", function() - local trace_info_captured = nil - - local next_func = function() - trace_info_captured = tracing.get_current_trace_info() - end - - local extract_headers = function(request) - return request.headers - end - - local middleware = http_server.create_generic_middleware(extract_headers) - - local mock_request = { - headers = { - ["SENTRY-TRACE"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - } - - middleware(mock_request, {}, next_func) - - assert.equal("1234567890abcdef1234567890abcdef", trace_info_captured.trace_id) - end) - - it("should error for non-function extract_headers", function() - assert.has_error(function() - http_server.create_generic_middleware("not a function") - end, "extract_headers must be a function") - end) - end) - - describe("pegasus integration", function() - it("should wrap pegasus server start method", function() - local handler_called_with = nil - - local mock_server = { - start = function(self, handler) - handler_called_with = handler - return "server started" - end - } - - local user_handler = function(request, response) - return "user handler result" - end - - local wrapped_server = http_server.pegasus.wrap_server(mock_server) - local result = wrapped_server:start(user_handler) - - assert.equal("server started", result) - assert.is_not_nil(handler_called_with) - assert.is_function(handler_called_with) - assert.is_not_equal(user_handler, handler_called_with) -- Should be wrapped - end) - - it("should create middleware for pegasus", function() - local trace_info_captured = nil - - local middleware = http_server.pegasus.create_middleware() - - local next_func = function() - trace_info_captured = tracing.get_current_trace_info() - end - - local mock_request = { - headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - } - - middleware(mock_request, {}, next_func) - - assert.equal("1234567890abcdef1234567890abcdef", trace_info_captured.trace_id) - end) - end) - end) - - describe("End-to-End Integration", function() - it("should propagate trace from server to client", function() - -- Simulate incoming request with trace - local incoming_headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1", - ["baggage"] = "key1=value1,key2=value2" - } - - -- Continue trace in server - tracing.continue_trace_from_request(incoming_headers) - - -- Make outgoing request from server - local outgoing_headers = tracing.get_request_headers("https://downstream.com") - - -- Verify trace propagation - assert.is_not_nil(outgoing_headers["sentry-trace"]) - assert.is_not_nil(outgoing_headers["baggage"]) - - -- Parse outgoing trace header - local trace_data = tracing.headers.parse_sentry_trace(outgoing_headers["sentry-trace"]) - - -- Should have same trace ID but different span ID - assert.equal("1234567890abcdef1234567890abcdef", trace_data.trace_id) - assert.is_not_equal("abcdef1234567890", trace_data.span_id) -- New span ID - assert.equal(true, trace_data.sampled) -- Preserve sampling decision - end) - - it("should create complete trace context for events", function() - -- Start with incoming trace - local incoming_headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - - tracing.continue_trace_from_request(incoming_headers) - - -- Create error event - local event = { - type = "error", - message = "Something went wrong" - } - - -- Attach trace context - local event_with_trace = tracing.attach_trace_context_to_event(event) - - -- Verify trace context - assert.is_not_nil(event_with_trace.contexts) - assert.is_not_nil(event_with_trace.contexts.trace) - assert.equal("1234567890abcdef1234567890abcdef", event_with_trace.contexts.trace.trace_id) - assert.equal("abcdef1234567890", event_with_trace.contexts.trace.parent_span_id) - assert.is_not_nil(event_with_trace.contexts.trace.span_id) - end) - - it("should handle TwP mode correctly", function() - -- Start new trace (TwP mode - no spans, just trace propagation) - tracing.start_trace() - - -- Get headers for outgoing request - local headers = tracing.get_request_headers() - - -- Parse the trace header - local trace_data = tracing.headers.parse_sentry_trace(headers["sentry-trace"]) - - -- In TwP mode, sampled should be nil (deferred) - assert.is_nil(trace_data.sampled) - - -- Should still propagate trace ID and span ID - assert.is_not_nil(trace_data.trace_id) - assert.is_not_nil(trace_data.span_id) - end) - end) -end) \ No newline at end of file diff --git a/spec/sentry_spec.lua b/spec/sentry_spec.lua deleted file mode 100644 index 1b01d88..0000000 --- a/spec/sentry_spec.lua +++ /dev/null @@ -1,116 +0,0 @@ -describe("Sentry SDK", function() - local sentry - - before_each(function() - sentry = require("sentry.init") - end) - - describe("initialization", function() - it("should require a DSN", function() - assert.has_error(function() - sentry.init({}) - end, "Sentry DSN is required") - end) - - it("should initialize with valid config", function() - local client = sentry.init({ - dsn = "https://test@sentry.io/123456", - environment = "test", - test_transport = true - }) - - assert.is_not_nil(client) - end) - end) - - describe("capture_message", function() - before_each(function() - sentry.init({ - dsn = "https://test@sentry.io/123456", - debug = true, - test_transport = true - }) - end) - - it("should capture a simple message", function() - local event_id = sentry.capture_message("Test message") - assert.is_string(event_id) - end) - - it("should capture message with level", function() - local event_id = sentry.capture_message("Warning message", "warning") - assert.is_string(event_id) - end) - - it("should fail when not initialized", function() - sentry.close() - assert.has_error(function() - sentry.capture_message("Test") - end, "Sentry not initialized. Call sentry.init() first.") - end) - end) - - describe("context management", function() - before_each(function() - sentry.init({ - dsn = "https://test@sentry.io/123456", - test_transport = true - }) - end) - - it("should set user context", function() - sentry.set_user({id = "123", email = "test@example.com"}) - - -- Capture message should succeed - local event_id = sentry.capture_message("Test user context") - assert.is_not_nil(event_id) - end) - - it("should set tags", function() - sentry.set_tag("environment", "test") - sentry.set_tag("version", "0.0.6") - - -- Capture message should succeed - local event_id = sentry.capture_message("Test tags") - assert.is_not_nil(event_id) - end) - - it("should add breadcrumbs", function() - sentry.add_breadcrumb({ - message = "User clicked button", - category = "ui", - level = "info" - }) - - -- Capture message should succeed - local event_id = sentry.capture_message("Test breadcrumbs") - assert.is_not_nil(event_id) - end) - end) - - describe("with_scope", function() - before_each(function() - sentry.init({ - dsn = "https://test@sentry.io/123456", - test_transport = true - }) - end) - - it("should isolate scope changes", function() - sentry.set_tag("global", "value") - - local scoped_event_id - sentry.with_scope(function(scope) - scope:set_tag("scoped", "temporary") - scoped_event_id = sentry.capture_message("Scoped message") - end) - - -- Verify scoped message was captured - assert.is_not_nil(scoped_event_id) - - -- Capture another message outside scope - local global_event_id = sentry.capture_message("Global message") - assert.is_not_nil(global_event_id) - end) - end) -end) \ No newline at end of file diff --git a/spec/sentry_trace_headers_spec.lua b/spec/sentry_trace_headers_spec.lua deleted file mode 100644 index dd927f2..0000000 --- a/spec/sentry_trace_headers_spec.lua +++ /dev/null @@ -1,276 +0,0 @@ --- Tests for Sentry trace header parsing and generation --- Based on Sentry distributed tracing specification - -local headers = require("sentry.tracing.headers") - -describe("sentry-trace header", function() - describe("parsing", function() - it("should parse valid header with trace_id and span_id only", function() - local header = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000" - local result = headers.parse_sentry_trace(header) - - assert.is_not_nil(result) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", result.trace_id) - assert.are.equal("1000000000000000", result.span_id) - assert.is_nil(result.sampled) - end) - - it("should parse valid header with sampled=1", function() - local header = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1" - local result = headers.parse_sentry_trace(header) - - assert.is_not_nil(result) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", result.trace_id) - assert.are.equal("1000000000000000", result.span_id) - assert.is_true(result.sampled) - end) - - it("should parse valid header with sampled=0", function() - local header = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0" - local result = headers.parse_sentry_trace(header) - - assert.is_not_nil(result) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", result.trace_id) - assert.are.equal("1000000000000000", result.span_id) - assert.is_false(result.sampled) - end) - - it("should normalize trace_id and span_id to lowercase", function() - local header = "75302AC48A024BDE9A3B3734A82E36C8-1000000000000000-1" - local result = headers.parse_sentry_trace(header) - - assert.is_not_nil(result) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", result.trace_id) - assert.are.equal("1000000000000000", result.span_id) - end) - - it("should handle whitespace around header value", function() - local header = " 75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1 " - local result = headers.parse_sentry_trace(header) - - assert.is_not_nil(result) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", result.trace_id) - assert.are.equal("1000000000000000", result.span_id) - assert.is_true(result.sampled) - end) - - it("should ignore invalid sampled values and defer sampling", function() - local header = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-invalid" - local result = headers.parse_sentry_trace(header) - - assert.is_not_nil(result) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", result.trace_id) - assert.are.equal("1000000000000000", result.span_id) - assert.is_nil(result.sampled) -- Deferred sampling - end) - - -- Invalid header tests - it("should return nil for nil input", function() - local result = headers.parse_sentry_trace(nil) - assert.is_nil(result) - end) - - it("should return nil for empty string", function() - local result = headers.parse_sentry_trace("") - assert.is_nil(result) - end) - - it("should return nil for whitespace only", function() - local result = headers.parse_sentry_trace(" ") - assert.is_nil(result) - end) - - it("should return nil for non-string input", function() - local result = headers.parse_sentry_trace(123) - assert.is_nil(result) - end) - - it("should return nil for header with missing span_id", function() - local header = "75302ac48a024bde9a3b3734a82e36c8" - local result = headers.parse_sentry_trace(header) - assert.is_nil(result) - end) - - it("should return nil for trace_id that is too short", function() - local header = "75302ac48a024bde-1000000000000000" - local result = headers.parse_sentry_trace(header) - assert.is_nil(result) - end) - - it("should return nil for trace_id that is too long", function() - local header = "75302ac48a024bde9a3b3734a82e36c8ab-1000000000000000" - local result = headers.parse_sentry_trace(header) - assert.is_nil(result) - end) - - it("should return nil for span_id that is too short", function() - local header = "75302ac48a024bde9a3b3734a82e36c8-10000000000000" - local result = headers.parse_sentry_trace(header) - assert.is_nil(result) - end) - - it("should return nil for span_id that is too long", function() - local header = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000ab" - local result = headers.parse_sentry_trace(header) - assert.is_nil(result) - end) - - it("should return nil for non-hex characters in trace_id", function() - local header = "75302ac48a024bdg9a3b3734a82e36c8-1000000000000000" - local result = headers.parse_sentry_trace(header) - assert.is_nil(result) - end) - - it("should return nil for non-hex characters in span_id", function() - local header = "75302ac48a024bde9a3b3734a82e36c8-1000000000000g00" - local result = headers.parse_sentry_trace(header) - assert.is_nil(result) - end) - end) - - describe("generation", function() - it("should generate valid sentry-trace header without sampled flag", function() - local trace_data = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000000" - } - - local result = headers.generate_sentry_trace(trace_data) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8-1000000000000000", result) - end) - - it("should generate valid sentry-trace header with sampled=true", function() - local trace_data = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000000", - sampled = true - } - - local result = headers.generate_sentry_trace(trace_data) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", result) - end) - - it("should generate valid sentry-trace header with sampled=false", function() - local trace_data = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000000", - sampled = false - } - - local result = headers.generate_sentry_trace(trace_data) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8-1000000000000000-0", result) - end) - - it("should normalize trace_id to lowercase", function() - local trace_data = { - trace_id = "75302AC48A024BDE9A3B3734A82E36C8", - span_id = "1000000000000000" - } - - local result = headers.generate_sentry_trace(trace_data) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8-1000000000000000", result) - end) - - it("should normalize span_id to lowercase", function() - local trace_data = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000ABC" - } - - local result = headers.generate_sentry_trace(trace_data) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8-1000000000000abc", result) - end) - - it("should omit sampled flag when sampled is nil", function() - local trace_data = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000000", - sampled = nil - } - - local result = headers.generate_sentry_trace(trace_data) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8-1000000000000000", result) - end) - - -- Error cases - it("should return nil for nil input", function() - local result = headers.generate_sentry_trace(nil) - assert.is_nil(result) - end) - - it("should return nil for non-table input", function() - local result = headers.generate_sentry_trace("invalid") - assert.is_nil(result) - end) - - it("should return nil for missing trace_id", function() - local trace_data = { - span_id = "1000000000000000" - } - - local result = headers.generate_sentry_trace(trace_data) - assert.is_nil(result) - end) - - it("should return nil for missing span_id", function() - local trace_data = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8" - } - - local result = headers.generate_sentry_trace(trace_data) - assert.is_nil(result) - end) - - it("should return nil for invalid trace_id length", function() - local trace_data = { - trace_id = "75302ac48a024bde", - span_id = "1000000000000000" - } - - local result = headers.generate_sentry_trace(trace_data) - assert.is_nil(result) - end) - - it("should return nil for invalid span_id length", function() - local trace_data = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000" - } - - local result = headers.generate_sentry_trace(trace_data) - assert.is_nil(result) - end) - end) - - describe("round-trip consistency", function() - it("should parse what it generates", function() - local original = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000000", - sampled = true - } - - local header = headers.generate_sentry_trace(original) - local parsed = headers.parse_sentry_trace(header) - - assert.are.equal(original.trace_id, parsed.trace_id) - assert.are.equal(original.span_id, parsed.span_id) - assert.are.equal(original.sampled, parsed.sampled) - end) - - it("should handle deferred sampling in round-trip", function() - local original = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000000" - -- sampled is nil (deferred) - } - - local header = headers.generate_sentry_trace(original) - local parsed = headers.parse_sentry_trace(header) - - assert.are.equal(original.trace_id, parsed.trace_id) - assert.are.equal(original.span_id, parsed.span_id) - assert.is_nil(parsed.sampled) - end) - end) -end) \ No newline at end of file diff --git a/spec/trace_propagation_spec.lua b/spec/trace_propagation_spec.lua deleted file mode 100644 index ebc1967..0000000 --- a/spec/trace_propagation_spec.lua +++ /dev/null @@ -1,306 +0,0 @@ --- Tests for trace propagation context management --- Tests the core logic that manages distributed tracing state - -local propagation = require("sentry.tracing.propagation") -local headers = require("sentry.tracing.headers") - -describe("trace propagation", function() - before_each(function() - -- Clear any existing context before each test - propagation.clear_context() - end) - - after_each(function() - -- Clean up after each test - propagation.clear_context() - end) - - describe("context management", function() - it("should start with no active context", function() - local context = propagation.get_current_context() - assert.is_nil(context) - end) - - it("should create new trace context", function() - local context = propagation.start_new_trace() - - assert.is_not_nil(context) - assert.is_not_nil(context.trace_id) - assert.is_not_nil(context.span_id) - assert.is_nil(context.parent_span_id) -- Root span - assert.are.equal(32, #context.trace_id) -- 128-bit as hex - assert.are.equal(16, #context.span_id) -- 64-bit as hex - end) - - it("should set and get current context", function() - local test_context = { - trace_id = "75302ac48a024bde9a3b3734a82e36c8", - span_id = "1000000000000000", - parent_span_id = nil, - sampled = true - } - - propagation.set_current_context(test_context) - local retrieved = propagation.get_current_context() - - assert.are.equal(test_context.trace_id, retrieved.trace_id) - assert.are.equal(test_context.span_id, retrieved.span_id) - assert.are.equal(test_context.sampled, retrieved.sampled) - end) - - it("should clear context", function() - propagation.start_new_trace() - assert.is_not_nil(propagation.get_current_context()) - - propagation.clear_context() - assert.is_nil(propagation.get_current_context()) - end) - - it("should report tracing as enabled when context exists", function() - assert.is_false(propagation.is_tracing_enabled()) - - propagation.start_new_trace() - assert.is_true(propagation.is_tracing_enabled()) - - propagation.clear_context() - assert.is_false(propagation.is_tracing_enabled()) - end) - end) - - describe("trace continuation from headers", function() - it("should continue trace from sentry-trace header", function() - local http_headers = { - ["sentry-trace"] = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.is_not_nil(context) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", context.trace_id) - assert.are.equal("1000000000000000", context.parent_span_id) -- Incoming span becomes parent - assert.is_not_nil(context.span_id) -- New span ID generated - assert.is_true(context.sampled) - assert.are.not_equal("1000000000000000", context.span_id) -- Should be new span ID - end) - - it("should continue trace from W3C traceparent header", function() - local http_headers = { - ["traceparent"] = "00-75302ac48a024bde9a3b3734a82e36c8-1000000000000000-01" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.is_not_nil(context) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", context.trace_id) - assert.are.equal("1000000000000000", context.parent_span_id) - assert.is_true(context.sampled) - end) - - it("should prefer sentry-trace over traceparent", function() - local http_headers = { - ["sentry-trace"] = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", - ["traceparent"] = "00-differenttraceid123456789abcdef0-2000000000000000-01" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.is_not_nil(context) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", context.trace_id) - assert.are.equal("1000000000000000", context.parent_span_id) - end) - - it("should handle case-insensitive header names", function() - local http_headers = { - ["Sentry-Trace"] = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.is_not_nil(context) - assert.are.equal("75302ac48a024bde9a3b3734a82e36c8", context.trace_id) - end) - - it("should parse baggage header", function() - local http_headers = { - ["sentry-trace"] = "75302ac48a024bde9a3b3734a82e36c8-1000000000000000-1", - ["baggage"] = "sentry-environment=production,sentry-release=1.0.0" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.is_not_nil(context) - assert.is_not_nil(context.baggage) - assert.are.equal("production", context.baggage["sentry-environment"]) - assert.are.equal("1.0.0", context.baggage["sentry-release"]) - end) - - it("should start new trace when no valid headers present", function() - local http_headers = { - ["some-other-header"] = "value" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.is_not_nil(context) - assert.is_not_nil(context.trace_id) - assert.is_not_nil(context.span_id) - assert.is_nil(context.parent_span_id) -- New root trace - end) - - it("should handle invalid sentry-trace header gracefully", function() - local http_headers = { - ["sentry-trace"] = "invalid-header-format" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.is_not_nil(context) - assert.is_not_nil(context.trace_id) - assert.is_nil(context.parent_span_id) -- Falls back to new trace - end) - end) - - describe("trace header generation", function() - it("should generate headers for outgoing requests", function() - local context = propagation.start_new_trace() - - local headers_out = propagation.get_trace_headers_for_request("http://example.com") - - assert.is_not_nil(headers_out["sentry-trace"]) - assert.is_not_nil(headers_out["sentry-trace"]:match(context.trace_id .. "%-" .. context.span_id)) - end) - - it("should include baggage in outgoing headers", function() - local context = propagation.start_new_trace({ - baggage = { - ["sentry-environment"] = "test" - } - }) - context.baggage = { ["sentry-environment"] = "test" } - propagation.set_current_context(context) - - local headers_out = propagation.get_trace_headers_for_request("http://example.com") - - assert.is_not_nil(headers_out["baggage"]) - assert.is_not_nil(headers_out["baggage"]:match("sentry%-environment=test")) - end) - - it("should return empty headers when no context", function() - -- No trace context set - local headers_out = propagation.get_trace_headers_for_request("http://example.com") - - assert.are.equal("table", type(headers_out)) - assert.is_nil(headers_out["sentry-trace"]) - end) - end) - - describe("trace propagation targeting", function() - it("should propagate to all targets with wildcard", function() - local context = propagation.start_new_trace() - local options = { - trace_propagation_targets = {"*"} - } - - local headers1 = propagation.get_trace_headers_for_request("http://example.com", options) - local headers2 = propagation.get_trace_headers_for_request("https://api.service.com", options) - - assert.is_not_nil(headers1["sentry-trace"]) - assert.is_not_nil(headers2["sentry-trace"]) - end) - - it("should propagate only to matching targets", function() - local context = propagation.start_new_trace() - local options = { - trace_propagation_targets = {"api.service.com"} - } - - local matching_headers = propagation.get_trace_headers_for_request("https://api.service.com/endpoint", options) - local non_matching_headers = propagation.get_trace_headers_for_request("https://other.com/endpoint", options) - - assert.is_not_nil(matching_headers["sentry-trace"]) - assert.is_nil(non_matching_headers["sentry-trace"]) - end) - - it("should not propagate when no targets match", function() - local context = propagation.start_new_trace() - local options = { - trace_propagation_targets = {"specific.domain.com"} - } - - local headers = propagation.get_trace_headers_for_request("https://other.domain.com", options) - - assert.is_nil(headers["sentry-trace"]) - end) - - it("should propagate to all when no targeting configured", function() - local context = propagation.start_new_trace() - local options = {} -- No trace_propagation_targets - - local headers = propagation.get_trace_headers_for_request("http://example.com", options) - - assert.is_not_nil(headers["sentry-trace"]) - end) - end) - - describe("child context creation", function() - it("should create child from existing context", function() - local parent = propagation.start_new_trace() - - local child = propagation.create_child_context() - - assert.is_not_nil(child) - assert.are.equal(parent.trace_id, child.trace_id) -- Same trace - assert.are.equal(parent.span_id, child.parent_span_id) -- Parent's span becomes parent_span_id - assert.are.not_equal(parent.span_id, child.span_id) -- New span ID - end) - - it("should start new trace when no parent context", function() - -- No existing context - local child = propagation.create_child_context() - - assert.is_not_nil(child) - assert.is_not_nil(child.trace_id) - assert.is_not_nil(child.span_id) - assert.is_nil(child.parent_span_id) -- Root trace - end) - end) - - describe("trace context for events", function() - it("should generate trace context for events", function() - local context = propagation.start_new_trace() - - local event_context = propagation.get_trace_context_for_event() - - assert.is_not_nil(event_context) - assert.are.equal(context.trace_id, event_context.trace_id) - assert.are.equal(context.span_id, event_context.span_id) - assert.are.equal(context.parent_span_id, event_context.parent_span_id) - end) - - it("should return nil when no trace context", function() - local event_context = propagation.get_trace_context_for_event() - assert.is_nil(event_context) - end) - end) - - describe("edge cases", function() - it("should handle nil headers gracefully", function() - local context = propagation.continue_trace_from_headers(nil) - assert.is_not_nil(context) -- Should create new trace - end) - - it("should handle empty headers table", function() - local context = propagation.continue_trace_from_headers({}) - assert.is_not_nil(context) -- Should create new trace - end) - - it("should generate valid trace and span IDs", function() - local context = propagation.start_new_trace() - - assert.is_not_nil(context.trace_id:match("^[0-9a-f]+$")) - assert.is_not_nil(context.span_id:match("^[0-9a-f]+$")) - assert.are.equal(32, #context.trace_id) - assert.are.equal(16, #context.span_id) - end) - end) -end) \ No newline at end of file diff --git a/spec/tracing/headers_spec.lua b/spec/tracing/headers_spec.lua deleted file mode 100644 index e38da09..0000000 --- a/spec/tracing/headers_spec.lua +++ /dev/null @@ -1,344 +0,0 @@ -local headers = require("sentry.tracing.headers") - -describe("sentry.tracing.headers", function() - describe("parse_sentry_trace", function() - it("should parse valid sentry-trace header", function() - local trace_data = headers.parse_sentry_trace("1234567890abcdef1234567890abcdef-1234567890abcdef-1") - - assert.is_not_nil(trace_data) - assert.equal("1234567890abcdef1234567890abcdef", trace_data.trace_id) - assert.equal("1234567890abcdef", trace_data.span_id) - assert.equal(true, trace_data.sampled) - end) - - it("should parse valid sentry-trace header without sampled flag", function() - local trace_data = headers.parse_sentry_trace("abcdef1234567890abcdef1234567890-abcdef1234567890") - - assert.is_not_nil(trace_data) - assert.equal("abcdef1234567890abcdef1234567890", trace_data.trace_id) - assert.equal("abcdef1234567890", trace_data.span_id) - assert.is_nil(trace_data.sampled) - end) - - it("should parse sampled=0 as false", function() - local trace_data = headers.parse_sentry_trace("1234567890abcdef1234567890abcdef-1234567890abcdef-0") - - assert.is_not_nil(trace_data) - assert.equal(false, trace_data.sampled) - end) - - it("should normalize trace_id and span_id to lowercase", function() - local trace_data = headers.parse_sentry_trace("ABCDEF1234567890ABCDEF1234567890-ABCDEF1234567890-1") - - assert.is_not_nil(trace_data) - assert.equal("abcdef1234567890abcdef1234567890", trace_data.trace_id) - assert.equal("abcdef1234567890", trace_data.span_id) - end) - - it("should handle whitespace", function() - local trace_data = headers.parse_sentry_trace(" 1234567890abcdef1234567890abcdef-1234567890abcdef-1 ") - - assert.is_not_nil(trace_data) - assert.equal("1234567890abcdef1234567890abcdef", trace_data.trace_id) - assert.equal("1234567890abcdef", trace_data.span_id) - assert.equal(true, trace_data.sampled) - end) - - it("should return nil for invalid header values", function() - assert.is_nil(headers.parse_sentry_trace(nil)) - assert.is_nil(headers.parse_sentry_trace("")) - assert.is_nil(headers.parse_sentry_trace(" ")) - assert.is_nil(headers.parse_sentry_trace("invalid-format")) - assert.is_nil(headers.parse_sentry_trace("too-short")) - assert.is_nil(headers.parse_sentry_trace("not-hex-characters-here123456789012-1234567890abcdef")) - end) - - it("should return nil for invalid trace_id length", function() - assert.is_nil(headers.parse_sentry_trace("short-1234567890abcdef-1")) - assert.is_nil(headers.parse_sentry_trace("toolong1234567890abcdef1234567890ab-1234567890abcdef-1")) - end) - - it("should return nil for invalid span_id length", function() - assert.is_nil(headers.parse_sentry_trace("1234567890abcdef1234567890abcdef-short-1")) - assert.is_nil(headers.parse_sentry_trace("1234567890abcdef1234567890abcdef-toolong567890abcdef-1")) - end) - - it("should ignore invalid sampled values", function() - local trace_data = headers.parse_sentry_trace("1234567890abcdef1234567890abcdef-1234567890abcdef-invalid") - - assert.is_not_nil(trace_data) - assert.is_nil(trace_data.sampled) -- Should defer sampling decision - end) - end) - - describe("generate_sentry_trace", function() - it("should generate valid sentry-trace header", function() - local trace_data = { - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "1234567890abcdef", - sampled = true - } - - local header = headers.generate_sentry_trace(trace_data) - assert.equal("1234567890abcdef1234567890abcdef-1234567890abcdef-1", header) - end) - - it("should generate header without sampled flag when sampled is nil", function() - local trace_data = { - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "1234567890abcdef" - } - - local header = headers.generate_sentry_trace(trace_data) - assert.equal("1234567890abcdef1234567890abcdef-1234567890abcdef", header) - end) - - it("should generate header with sampled=0 when sampled is false", function() - local trace_data = { - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "1234567890abcdef", - sampled = false - } - - local header = headers.generate_sentry_trace(trace_data) - assert.equal("1234567890abcdef1234567890abcdef-1234567890abcdef-0", header) - end) - - it("should normalize to lowercase", function() - local trace_data = { - trace_id = "ABCDEF1234567890ABCDEF1234567890", - span_id = "ABCDEF1234567890" - } - - local header = headers.generate_sentry_trace(trace_data) - assert.equal("abcdef1234567890abcdef1234567890-abcdef1234567890", header) - end) - - it("should return nil for invalid input", function() - assert.is_nil(headers.generate_sentry_trace(nil)) - assert.is_nil(headers.generate_sentry_trace({})) - assert.is_nil(headers.generate_sentry_trace({trace_id = "invalid"})) - assert.is_nil(headers.generate_sentry_trace({span_id = "invalid"})) - assert.is_nil(headers.generate_sentry_trace({ - trace_id = "short", - span_id = "1234567890abcdef" - })) - end) - end) - - describe("parse_baggage", function() - it("should parse simple baggage header", function() - local baggage = headers.parse_baggage("key1=value1,key2=value2") - - assert.equal("value1", baggage.key1) - assert.equal("value2", baggage.key2) - end) - - it("should parse baggage with properties (ignore properties)", function() - local baggage = headers.parse_baggage("key1=value1;property=prop,key2=value2") - - assert.equal("value1", baggage.key1) - assert.equal("value2", baggage.key2) - end) - - it("should handle whitespace", function() - local baggage = headers.parse_baggage(" key1=value1 , key2=value2 ") - - assert.equal("value1", baggage.key1) - assert.equal("value2", baggage.key2) - end) - - it("should return empty table for invalid input", function() - local baggage1 = headers.parse_baggage(nil) - local baggage2 = headers.parse_baggage("") - local baggage3 = headers.parse_baggage(" ") - - assert.same({}, baggage1) - assert.same({}, baggage2) - assert.same({}, baggage3) - end) - - it("should ignore invalid key-value pairs", function() - local baggage = headers.parse_baggage("key1=value1,invalid_entry,key2=value2") - - assert.equal("value1", baggage.key1) - assert.equal("value2", baggage.key2) - assert.is_nil(baggage.invalid_entry) - end) - end) - - describe("generate_baggage", function() - it("should generate valid baggage header", function() - local baggage_data = { - key1 = "value1", - key2 = "value2" - } - - local header = headers.generate_baggage(baggage_data) - - -- Order is not guaranteed, so check both possibilities - local expected1 = "key1=value1,key2=value2" - local expected2 = "key2=value2,key1=value1" - - assert.is_true(header == expected1 or header == expected2) - end) - - it("should URL encode special characters", function() - local baggage_data = { - key1 = "value,with,commas", - key2 = "value;with;semicolons", - key3 = "value=with=equals" - } - - local header = headers.generate_baggage(baggage_data) - - assert.is_true(header:find("value%%2Cwith%%2Ccommas") ~= nil) - assert.is_true(header:find("value%%3Bwith%%3Bsemicolons") ~= nil) - assert.is_true(header:find("value%%3Dwith%%3Dequals") ~= nil) - end) - - it("should return nil for empty or invalid input", function() - assert.is_nil(headers.generate_baggage(nil)) - assert.is_nil(headers.generate_baggage({})) - assert.is_nil(headers.generate_baggage({key1 = 123})) -- Non-string value - end) - end) - - describe("generate_trace_id", function() - it("should generate valid trace IDs", function() - local id1 = headers.generate_trace_id() - local id2 = headers.generate_trace_id() - - assert.equal(32, #id1) - assert.equal(32, #id2) - assert.is_not_equal(id1, id2) -- Should be unique - assert.is_true(id1:match("^[0-9a-f]+$") ~= nil) -- Should be hex - end) - end) - - describe("generate_span_id", function() - it("should generate valid span IDs", function() - local id1 = headers.generate_span_id() - local id2 = headers.generate_span_id() - - assert.equal(16, #id1) - assert.equal(16, #id2) - assert.is_not_equal(id1, id2) -- Should be unique - assert.is_true(id1:match("^[0-9a-f]+$") ~= nil) -- Should be hex - end) - end) - - describe("extract_trace_headers", function() - it("should extract sentry-trace header", function() - local http_headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-1234567890abcdef-1", - ["content-type"] = "application/json" - } - - local trace_info = headers.extract_trace_headers(http_headers) - - assert.is_not_nil(trace_info.sentry_trace) - assert.equal("1234567890abcdef1234567890abcdef", trace_info.sentry_trace.trace_id) - end) - - it("should extract baggage header", function() - local http_headers = { - ["baggage"] = "key1=value1,key2=value2" - } - - local trace_info = headers.extract_trace_headers(http_headers) - - assert.is_not_nil(trace_info.baggage) - assert.equal("value1", trace_info.baggage.key1) - assert.equal("value2", trace_info.baggage.key2) - end) - - it("should be case-insensitive for header names", function() - local http_headers = { - ["SENTRY-TRACE"] = "1234567890abcdef1234567890abcdef-1234567890abcdef-1", - ["Baggage"] = "key1=value1" - } - - local trace_info = headers.extract_trace_headers(http_headers) - - assert.is_not_nil(trace_info.sentry_trace) - assert.is_not_nil(trace_info.baggage) - end) - - it("should extract traceparent header", function() - local http_headers = { - ["traceparent"] = "00-1234567890abcdef1234567890abcdef-1234567890abcdef-01" - } - - local trace_info = headers.extract_trace_headers(http_headers) - - assert.equal("00-1234567890abcdef1234567890abcdef-1234567890abcdef-01", trace_info.traceparent) - end) - - it("should return empty table for invalid input", function() - local trace_info1 = headers.extract_trace_headers(nil) - local trace_info2 = headers.extract_trace_headers({}) - - assert.same({}, trace_info1) - assert.same({}, trace_info2) - end) - end) - - describe("inject_trace_headers", function() - it("should inject sentry-trace header", function() - local http_headers = {} - local trace_data = { - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "1234567890abcdef", - sampled = true - } - - headers.inject_trace_headers(http_headers, trace_data) - - assert.equal("1234567890abcdef1234567890abcdef-1234567890abcdef-1", http_headers["sentry-trace"]) - end) - - it("should inject baggage header", function() - local http_headers = {} - local trace_data = { - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "1234567890abcdef" - } - local baggage_data = { - key1 = "value1", - key2 = "value2" - } - - headers.inject_trace_headers(http_headers, trace_data, baggage_data) - - assert.is_not_nil(http_headers["baggage"]) - assert.is_true(http_headers["baggage"]:find("key1=value1") ~= nil) - assert.is_true(http_headers["baggage"]:find("key2=value2") ~= nil) - end) - - it("should inject traceparent header when requested", function() - local http_headers = {} - local trace_data = { - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "1234567890abcdef", - sampled = true - } - local options = { - include_traceparent = true - } - - headers.inject_trace_headers(http_headers, trace_data, nil, options) - - assert.equal("00-1234567890abcdef1234567890abcdef-1234567890abcdef-01", http_headers["traceparent"]) - end) - - it("should not modify headers for invalid input", function() - local http_headers = {existing = "value"} - - headers.inject_trace_headers(http_headers, nil) - - assert.equal("value", http_headers.existing) - assert.is_nil(http_headers["sentry-trace"]) - end) - end) -end) \ No newline at end of file diff --git a/spec/tracing/init_spec.lua b/spec/tracing/init_spec.lua deleted file mode 100644 index 5548173..0000000 --- a/spec/tracing/init_spec.lua +++ /dev/null @@ -1,378 +0,0 @@ -local tracing = require("sentry.tracing") - -describe("sentry.tracing", function() - before_each(function() - -- Clear any existing context before each test - tracing.clear() - end) - - after_each(function() - -- Clean up after each test - tracing.clear() - end) - - describe("init", function() - it("should initialize tracing with default config", function() - tracing.init() - - assert.equal(true, tracing.is_active()) - end) - - it("should initialize tracing with custom config", function() - local config = { - trace_propagation_targets = {"example%.com"}, - include_traceparent = true - } - - tracing.init(config) - - assert.equal(true, tracing.is_active()) - assert.same(config, tracing._config) - end) - end) - - describe("continue_trace_from_request", function() - it("should continue trace from request headers", function() - local request_headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - - local trace_context = tracing.continue_trace_from_request(request_headers) - - assert.is_not_nil(trace_context) - assert.equal("1234567890abcdef1234567890abcdef", trace_context.trace_id) - assert.equal("abcdef1234567890", trace_context.parent_span_id) - end) - - it("should start new trace when no headers", function() - local request_headers = {} - - local trace_context = tracing.continue_trace_from_request(request_headers) - - assert.is_not_nil(trace_context) - assert.is_not_nil(trace_context.trace_id) - assert.is_nil(trace_context.parent_span_id) - end) - end) - - describe("get_request_headers", function() - it("should return empty table when not active", function() - tracing.clear() - local headers = tracing.get_request_headers() - assert.same({}, headers) - end) - - it("should return sentry-trace header when active", function() - tracing.start_trace() - - local headers = tracing.get_request_headers() - - assert.is_not_nil(headers["sentry-trace"]) - end) - - it("should use config for traceparent inclusion", function() - tracing.init({include_traceparent = true}) - tracing.start_trace() - - local headers = tracing.get_request_headers() - - assert.is_not_nil(headers["sentry-trace"]) - assert.is_not_nil(headers["traceparent"]) - end) - - it("should respect trace propagation targets from config", function() - tracing.init({trace_propagation_targets = {"^https://example%.com"}}) - tracing.start_trace() - - local headers1 = tracing.get_request_headers("https://other.com") - local headers2 = tracing.get_request_headers("https://example.com") - - assert.same({}, headers1) -- Should not propagate - assert.is_not_nil(headers2["sentry-trace"]) -- Should propagate - end) - end) - - describe("start_trace", function() - it("should start new trace", function() - local trace_context = tracing.start_trace() - - assert.is_not_nil(trace_context) - assert.is_not_nil(trace_context.trace_id) - assert.is_not_nil(trace_context.span_id) - assert.is_nil(trace_context.parent_span_id) - end) - - it("should include options", function() - local options = { - baggage = {key1 = "value1"} - } - - local trace_context = tracing.start_trace(options) - - -- Note: baggage is stored in propagation context, not returned in trace_context - assert.is_not_nil(trace_context) - end) - end) - - describe("create_child", function() - it("should create child span context", function() - tracing.start_trace() - local parent_info = tracing.get_current_trace_info() - - local child_context = tracing.create_child() - - assert.equal(parent_info.trace_id, child_context.trace_id) - assert.equal(parent_info.span_id, child_context.parent_span_id) - assert.is_not_equal(parent_info.span_id, child_context.span_id) - end) - - it("should start new trace when no parent", function() - local child_context = tracing.create_child() - - assert.is_not_nil(child_context.trace_id) - assert.is_nil(child_context.parent_span_id) - end) - end) - - describe("get_current_trace_info", function() - it("should return nil when not active", function() - tracing.clear() - - local trace_info = tracing.get_current_trace_info() - assert.is_nil(trace_info) - end) - - it("should return trace info when active", function() - tracing.start_trace() - - local trace_info = tracing.get_current_trace_info() - - assert.is_not_nil(trace_info) - assert.is_not_nil(trace_info.trace_id) - assert.is_not_nil(trace_info.span_id) - assert.equal(true, trace_info.is_tracing_enabled) - end) - end) - - describe("is_active", function() - it("should return false when not active", function() - tracing.clear() - assert.equal(false, tracing.is_active()) - end) - - it("should return true when active", function() - tracing.start_trace() - assert.equal(true, tracing.is_active()) - end) - end) - - describe("attach_trace_context_to_event", function() - it("should not modify event when not active", function() - tracing.clear() - local event = {type = "error", message = "test"} - - local modified_event = tracing.attach_trace_context_to_event(event) - - assert.equal(event, modified_event) - assert.is_nil(modified_event.contexts) - end) - - it("should attach trace context when active", function() - tracing.start_trace() - local event = {type = "error", message = "test"} - - local modified_event = tracing.attach_trace_context_to_event(event) - - assert.equal(event, modified_event) - assert.is_not_nil(modified_event.contexts) - assert.is_not_nil(modified_event.contexts.trace) - assert.is_not_nil(modified_event.contexts.trace.trace_id) - end) - - it("should preserve existing contexts", function() - tracing.start_trace() - local event = { - type = "error", - message = "test", - contexts = { - runtime = {name = "lua", version = "5.4"} - } - } - - local modified_event = tracing.attach_trace_context_to_event(event) - - assert.is_not_nil(modified_event.contexts.runtime) - assert.is_not_nil(modified_event.contexts.trace) - end) - - it("should handle invalid input", function() - local result1 = tracing.attach_trace_context_to_event(nil) - local result2 = tracing.attach_trace_context_to_event("not a table") - - assert.is_nil(result1) - assert.equal("not a table", result2) - end) - end) - - describe("wrap_http_request", function() - it("should wrap HTTP request function with trace headers", function() - tracing.start_trace() - - local mock_client = function(url, options) - return { - url = url, - headers = options.headers or {} - } - end - - local result = tracing.wrap_http_request(mock_client, "https://example.com", { - method = "GET" - }) - - assert.equal("https://example.com", result.url) - assert.is_not_nil(result.headers["sentry-trace"]) - end) - - it("should merge with existing headers", function() - tracing.start_trace() - - local mock_client = function(url, options) - return options.headers - end - - local result = tracing.wrap_http_request(mock_client, "https://example.com", { - headers = { - ["content-type"] = "application/json" - } - }) - - assert.equal("application/json", result["content-type"]) - assert.is_not_nil(result["sentry-trace"]) - end) - - it("should error for non-function client", function() - assert.has_error(function() - tracing.wrap_http_request("not a function", "https://example.com") - end, "http_client must be a function") - end) - end) - - describe("wrap_http_handler", function() - it("should wrap handler with trace continuation", function() - local trace_id_captured = nil - - local mock_handler = function(request, response) - trace_id_captured = tracing.get_current_trace_info() - return "handled" - end - - local wrapped_handler = tracing.wrap_http_handler(mock_handler) - - local mock_request = { - headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - } - - local result = wrapped_handler(mock_request, {}) - - assert.equal("handled", result) - assert.is_not_nil(trace_id_captured) - assert.equal("1234567890abcdef1234567890abcdef", trace_id_captured.trace_id) - end) - - it("should handle requests without trace headers", function() - local trace_info_captured = nil - - local mock_handler = function(request, response) - trace_info_captured = tracing.get_current_trace_info() - end - - local wrapped_handler = tracing.wrap_http_handler(mock_handler) - wrapped_handler({headers = {}}, {}) - - assert.is_not_nil(trace_info_captured) -- Should have started new trace - end) - - it("should handle requests with get_header method", function() - local trace_id_captured = nil - - local mock_handler = function(request, response) - trace_id_captured = tracing.get_current_trace_info() - end - - local wrapped_handler = tracing.wrap_http_handler(mock_handler) - - local mock_request = { - get_header = function(self, name) - if name == "sentry-trace" then - return "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - end - return nil - end - } - - wrapped_handler(mock_request, {}) - - assert.is_not_nil(trace_id_captured) - assert.equal("1234567890abcdef1234567890abcdef", trace_id_captured.trace_id) - end) - - it("should propagate errors from handler", function() - local mock_handler = function(request, response) - error("handler error") - end - - local wrapped_handler = tracing.wrap_http_handler(mock_handler) - - assert.has_error(function() - wrapped_handler({headers = {}}, {}) - end) - end) - - it("should error for non-function handler", function() - assert.has_error(function() - tracing.wrap_http_handler("not a function") - end, "handler must be a function") - end) - end) - - describe("generate_ids", function() - it("should generate valid trace and span IDs", function() - local ids = tracing.generate_ids() - - assert.is_not_nil(ids.trace_id) - assert.is_not_nil(ids.span_id) - assert.equal(32, #ids.trace_id) -- 128-bit as hex - assert.equal(16, #ids.span_id) -- 64-bit as hex - assert.is_true(ids.trace_id:match("^[0-9a-f]+$") ~= nil) - assert.is_true(ids.span_id:match("^[0-9a-f]+$") ~= nil) - end) - - it("should generate unique IDs", function() - local ids1 = tracing.generate_ids() - local ids2 = tracing.generate_ids() - - assert.is_not_equal(ids1.trace_id, ids2.trace_id) - assert.is_not_equal(ids1.span_id, ids2.span_id) - end) - end) - - describe("get_envelope_trace_header", function() - it("should return nil when not active", function() - tracing.clear() - - local header = tracing.get_envelope_trace_header() - assert.is_nil(header) - end) - - it("should return DSC when active", function() - tracing.start_trace() - - local header = tracing.get_envelope_trace_header() - - assert.is_not_nil(header) - assert.is_not_nil(header["sentry-trace_id"]) - end) - end) -end) \ No newline at end of file diff --git a/spec/tracing/propagation_spec.lua b/spec/tracing/propagation_spec.lua deleted file mode 100644 index bdc7967..0000000 --- a/spec/tracing/propagation_spec.lua +++ /dev/null @@ -1,361 +0,0 @@ -local propagation = require("sentry.tracing.propagation") - -describe("sentry.tracing.propagation", function() - before_each(function() - -- Clear any existing context before each test - propagation.clear_context() - end) - - after_each(function() - -- Clean up after each test - propagation.clear_context() - end) - - describe("create_context", function() - it("should create new context without incoming trace data", function() - local context = propagation.create_context() - - assert.is_not_nil(context.trace_id) - assert.is_not_nil(context.span_id) - assert.is_nil(context.parent_span_id) - assert.is_nil(context.sampled) -- Deferred sampling in TwP mode - assert.same({}, context.baggage) - end) - - it("should continue context from incoming trace data", function() - local incoming_trace = { - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "abcdef1234567890", - sampled = true - } - - local context = propagation.create_context(incoming_trace) - - assert.equal("1234567890abcdef1234567890abcdef", context.trace_id) - assert.equal("abcdef1234567890", context.parent_span_id) -- Incoming span becomes parent - assert.is_not_nil(context.span_id) -- New span ID generated - assert.is_not_equal("abcdef1234567890", context.span_id) -- Should be different - assert.equal(true, context.sampled) - end) - - it("should include baggage data", function() - local baggage_data = { - key1 = "value1", - key2 = "value2" - } - - local context = propagation.create_context(nil, baggage_data) - - assert.equal("value1", context.baggage.key1) - assert.equal("value2", context.baggage.key2) - end) - end) - - describe("get_current_context and set_current_context", function() - it("should return nil when no context is set", function() - assert.is_nil(propagation.get_current_context()) - end) - - it("should store and retrieve context", function() - local context = propagation.create_context() - propagation.set_current_context(context) - - local retrieved = propagation.get_current_context() - assert.equal(context, retrieved) - end) - - it("should clear context", function() - local context = propagation.create_context() - propagation.set_current_context(context) - - propagation.clear_context() - assert.is_nil(propagation.get_current_context()) - end) - end) - - describe("continue_trace_from_headers", function() - it("should continue trace from sentry-trace header", function() - local http_headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.equal("1234567890abcdef1234567890abcdef", context.trace_id) - assert.equal("abcdef1234567890", context.parent_span_id) - assert.equal(true, context.sampled) - - -- Should also set as current context - assert.equal(context, propagation.get_current_context()) - end) - - it("should continue trace from W3C traceparent header", function() - local http_headers = { - ["traceparent"] = "00-1234567890abcdef1234567890abcdef-abcdef1234567890-01" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.equal("1234567890abcdef1234567890abcdef", context.trace_id) - assert.equal("abcdef1234567890", context.parent_span_id) - assert.equal(true, context.sampled) - end) - - it("should prioritize sentry-trace over traceparent", function() - local http_headers = { - ["sentry-trace"] = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-0", - ["traceparent"] = "00-1234567890abcdef1234567890abcdef-abcdef1234567890-01" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - -- Should use sentry-trace values - assert.equal("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", context.trace_id) - assert.equal("bbbbbbbbbbbbbbbb", context.parent_span_id) - assert.equal(false, context.sampled) - end) - - it("should include baggage data", function() - local http_headers = { - ["sentry-trace"] = "1234567890abcdef1234567890abcdef-abcdef1234567890-1", - ["baggage"] = "key1=value1,key2=value2" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.equal("value1", context.baggage.key1) - assert.equal("value2", context.baggage.key2) - end) - - it("should start new trace when no trace headers present", function() - local http_headers = { - ["content-type"] = "application/json" - } - - local context = propagation.continue_trace_from_headers(http_headers) - - assert.is_not_nil(context.trace_id) - assert.is_not_nil(context.span_id) - assert.is_nil(context.parent_span_id) - assert.is_nil(context.sampled) -- Deferred sampling - end) - end) - - describe("get_trace_headers_for_request", function() - it("should return empty table when no current context", function() - local headers = propagation.get_trace_headers_for_request() - assert.same({}, headers) - end) - - it("should generate sentry-trace header", function() - local context = propagation.create_context({ - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "abcdef1234567890", - sampled = true - }) - propagation.set_current_context(context) - - local headers = propagation.get_trace_headers_for_request() - - assert.is_not_nil(headers["sentry-trace"]) - assert.is_true(headers["sentry-trace"]:find("1234567890abcdef1234567890abcdef") ~= nil) - end) - - it("should include baggage header when baggage exists", function() - local context = propagation.create_context(nil, { - key1 = "value1", - key2 = "value2" - }) - propagation.set_current_context(context) - - local headers = propagation.get_trace_headers_for_request() - - assert.is_not_nil(headers["baggage"]) - assert.is_true(headers["baggage"]:find("key1=value1") ~= nil) - end) - - it("should include traceparent when requested", function() - local context = propagation.create_context({ - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "abcdef1234567890", - sampled = true - }) - propagation.set_current_context(context) - - local headers = propagation.get_trace_headers_for_request(nil, { - include_traceparent = true - }) - - assert.is_not_nil(headers["traceparent"]) - assert.is_true(headers["traceparent"]:find("1234567890abcdef1234567890abcdef") ~= nil) - end) - - it("should respect trace propagation targets", function() - local context = propagation.create_context() - propagation.set_current_context(context) - - local headers = propagation.get_trace_headers_for_request("https://example.com", { - trace_propagation_targets = {"notexample%.com"} - }) - - assert.same({}, headers) -- Should not propagate to non-matching targets - end) - - it("should propagate to matching targets", function() - local context = propagation.create_context() - propagation.set_current_context(context) - - local headers = propagation.get_trace_headers_for_request("https://example.com", { - trace_propagation_targets = {"example%.com"} - }) - - assert.is_not_nil(headers["sentry-trace"]) -- Should propagate to matching targets - end) - end) - - describe("get_trace_context_for_event", function() - it("should return nil when no context", function() - local trace_context = propagation.get_trace_context_for_event() - assert.is_nil(trace_context) - end) - - it("should return trace context for events", function() - local context = propagation.create_context({ - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "abcdef1234567890", - sampled = true - }) - propagation.set_current_context(context) - - local trace_context = propagation.get_trace_context_for_event() - - assert.equal("1234567890abcdef1234567890abcdef", trace_context.trace_id) - assert.equal("abcdef1234567890", trace_context.parent_span_id) -- Parent from incoming - assert.is_not_nil(trace_context.span_id) - end) - end) - - describe("start_new_trace", function() - it("should start new trace and set as current", function() - local context = propagation.start_new_trace() - - assert.is_not_nil(context.trace_id) - assert.is_not_nil(context.span_id) - assert.is_nil(context.parent_span_id) - assert.is_nil(context.sampled) -- Deferred sampling - - assert.equal(context, propagation.get_current_context()) - end) - - it("should include baggage options", function() - local options = { - baggage = { - key1 = "value1" - } - } - - local context = propagation.start_new_trace(options) - - assert.equal("value1", context.baggage.key1) - end) - end) - - describe("create_child_context", function() - it("should create child from current context", function() - local parent_context = propagation.create_context({ - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "abcdef1234567890", - sampled = true - }) - propagation.set_current_context(parent_context) - - local child_context = propagation.create_child_context() - - assert.equal(parent_context.trace_id, child_context.trace_id) -- Same trace ID - assert.equal(parent_context.span_id, child_context.parent_span_id) -- Parent span ID - assert.is_not_equal(parent_context.span_id, child_context.span_id) -- Different span ID - assert.equal(parent_context.sampled, child_context.sampled) -- Same sampling decision - end) - - it("should start new trace when no parent context", function() - local child_context = propagation.create_child_context() - - assert.is_not_nil(child_context.trace_id) - assert.is_not_nil(child_context.span_id) - assert.is_nil(child_context.parent_span_id) - end) - end) - - describe("utility functions", function() - describe("is_tracing_enabled", function() - it("should return false when no context", function() - assert.equal(false, propagation.is_tracing_enabled()) - end) - - it("should return true when context exists", function() - local context = propagation.create_context() - propagation.set_current_context(context) - - assert.equal(true, propagation.is_tracing_enabled()) - end) - end) - - describe("get_current_trace_id", function() - it("should return nil when no context", function() - assert.is_nil(propagation.get_current_trace_id()) - end) - - it("should return trace ID when context exists", function() - local context = propagation.create_context({ - trace_id = "1234567890abcdef1234567890abcdef", - span_id = "abcdef1234567890" - }) - propagation.set_current_context(context) - - assert.equal("1234567890abcdef1234567890abcdef", propagation.get_current_trace_id()) - end) - end) - - describe("get_current_span_id", function() - it("should return nil when no context", function() - assert.is_nil(propagation.get_current_span_id()) - end) - - it("should return span ID when context exists", function() - local context = propagation.create_context() - propagation.set_current_context(context) - - local span_id = propagation.get_current_span_id() - assert.is_not_nil(span_id) - assert.equal(16, #span_id) -- Should be 16 hex characters - end) - end) - end) - - describe("get_dynamic_sampling_context", function() - it("should return nil when no context", function() - assert.is_nil(propagation.get_dynamic_sampling_context()) - end) - - it("should return DSC when context exists", function() - local context = propagation.create_context() - propagation.set_current_context(context) - - local dsc = propagation.get_dynamic_sampling_context() - - assert.is_not_nil(dsc) - assert.equal(context.trace_id, dsc["sentry-trace_id"]) - end) - - it("should return copy of DSC to avoid mutations", function() - local context = propagation.create_context() - propagation.set_current_context(context) - - local dsc1 = propagation.get_dynamic_sampling_context() - local dsc2 = propagation.get_dynamic_sampling_context() - - assert.is_not_equal(dsc1, dsc2) -- Should be different table instances - assert.same(dsc1, dsc2) -- But with same content - end) - end) -end) \ No newline at end of file diff --git a/src/sentry/core/auto_transport.tl b/src/sentry/core/auto_transport.tl deleted file mode 100644 index 011c547..0000000 --- a/src/sentry/core/auto_transport.tl +++ /dev/null @@ -1,64 +0,0 @@ -local transport = require("sentry.core.transport") -local file_io = require("sentry.core.file_io") -local FileTransport = require("sentry.core.file_transport") - -local function detect_platform(): string - if game and game.GetService then - return "roblox" - elseif ngx and ngx.say then - return "nginx" - elseif redis and redis.call then - return "redis" - elseif love and love.graphics then - return "love2d" - elseif sys and sys.get_save_file then - return "defold" - elseif _G.corona then - return "solar2d" - else - return "standard" - end -end - -local function create_auto_transport(config: table): any - local platform = detect_platform() - - if platform == "roblox" then - local roblox_integration = require("sentry.integrations.roblox") - local RobloxTransport = roblox_integration.setup_roblox_integration() - return RobloxTransport:configure(config) - - elseif platform == "nginx" then - local nginx_integration = require("sentry.integrations.nginx") - local NginxTransport = nginx_integration.setup_nginx_integration() - return NginxTransport:configure(config) - - elseif platform == "redis" then - local redis_integration = require("sentry.integrations.redis") - local RedisTransport = redis_integration.setup_redis_integration() - return RedisTransport:configure(config) - - elseif platform == "love2d" then - local love2d_integration = require("sentry.integrations.love2d") - local Love2DTransport = love2d_integration.setup_love2d_integration() - return Love2DTransport:configure(config) - - elseif platform == "defold" then - local defold_file_io = require("sentry.integrations.defold_file_io") - local file_transport = FileTransport:configure({ - dsn = (config as any).dsn, - file_path = "defold-sentry.log", - file_io = defold_file_io.create_defold_file_io() - }) - return file_transport - - else - local HttpTransport = transport.HttpTransport - return HttpTransport:configure(config) - end -end - -return { - detect_platform = detect_platform, - create_auto_transport = create_auto_transport -} \ No newline at end of file diff --git a/src/sentry/core/client.tl b/src/sentry/core/client.tl deleted file mode 100644 index 2ae417e..0000000 --- a/src/sentry/core/client.tl +++ /dev/null @@ -1,173 +0,0 @@ -local transport = require("sentry.core.transport") -local Scope = require("sentry.core.scope") -local stacktrace = require("sentry.utils.stacktrace") -local serialize = require("sentry.utils.serialize") -local runtime_utils = require("sentry.utils.runtime") -local os_utils = require("sentry.utils.os") -local types = require("sentry.types") - --- Load platform detectors -require("sentry.platform_loader") - -local SentryOptions = types.SentryOptions - -local record Client - options: SentryOptions -- Configuration from sentry.init() - scope: Scope -- Current scope with user, tags, contexts, etc. - transport: any - enabled: boolean -end - -function Client:new(options: SentryOptions): Client - local client = setmetatable({ - options = options or {} as SentryOptions, - scope = Scope:new(), - enabled = true - }, {__index = Client}) as Client - - -- Configure transport using the registry system - if options.transport then - -- User-provided transport takes precedence - client.transport = options.transport:configure(options) - else - -- Use transport registry to find best available transport - client.transport = transport.create_transport(options) - end - - -- Configure scope max breadcrumbs from options - if options.max_breadcrumbs then - client.scope.max_breadcrumbs = options.max_breadcrumbs - end - - -- Initialize runtime context in scope - local runtime_info = runtime_utils.get_runtime_info() - client.scope:set_context("runtime", { - name = runtime_info.name, - version = runtime_info.version, - description = runtime_info.description - }) - - -- Initialize OS context in scope (if available) - local os_info = os_utils.get_os_info() - if os_info then - local os_context: {string: string} = { - name = os_info.name - } - -- Only add version if it's not nil - if os_info.version then - os_context.version = os_info.version - end - client.scope:set_context("os", os_context) - end - - -- Install Love2D error handler integration if in Love2D environment - if runtime_info.name == "love2d" and (_G as any).love then - local ok, love2d_integration = pcall(require, "sentry.platforms.love2d.integration") - if ok then - local integration = love2d_integration.setup_love2d_integration() - integration:install_error_handler(client) - -- Store integration reference for potential cleanup - client.love2d_integration = integration - end - end - - return client -end - -function Client:is_enabled(): boolean - return self.enabled and self.options.dsn and self.options.dsn ~= "" -end - -function Client:capture_message(message: string, level: string): string - if not self:is_enabled() then - return "" - end - - level = level or "info" - local stack_trace = stacktrace.get_stack_trace(1) - - -- Create basic event - local event = serialize.create_event(level, message, self.options.environment or "production", self.options.release, stack_trace) - - -- Apply scope data to event - event = self.scope:apply_to_event(event) - - if self.options.before_send then - event = self.options.before_send(event) as types.EventData - if not event then - return "" - end - end - - local success, err = (self.transport as any):send(event) - - if self.options.debug then - if success then - print("[Sentry] Event sent: " .. event.event_id) - else - print("[Sentry] Failed to send event: " .. tostring(err)) - end - end - - return success and event.event_id or "" -end - -function Client:capture_exception(exception: table, level: string): string - if not self:is_enabled() then - return "" - end - - level = level or "error" - local stack_trace = stacktrace.get_stack_trace(1) - - local event = serialize.create_event(level, (exception as any).message or "Exception", self.options.environment or "production", self.options.release, stack_trace) - event = self.scope:apply_to_event(event) - (event as any).exception = { - values = {{ - type = (exception as any).type or "Error", - value = (exception as any).message or "Unknown error", - stacktrace = stack_trace - }} - } - - if self.options.before_send then - event = self.options.before_send(event as any) as any - if not event then - return "" - end - end - - local success, err = (self.transport as any):send(event) - - if self.options.debug then - if success then - print("[Sentry] Exception sent: " .. event.event_id) - else - print("[Sentry] Failed to send exception: " .. tostring(err)) - end - end - - return success and event.event_id or "" -end - -function Client:add_breadcrumb(breadcrumb: table) - self.scope:add_breadcrumb(breadcrumb) -end - -function Client:set_user(user: table) - self.scope:set_user(user) -end - -function Client:set_tag(key: string, value: string) - self.scope:set_tag(key, value) -end - -function Client:set_extra(key: string, value: any) - self.scope:set_extra(key, value) -end - -function Client:close() - self.enabled = false -end - -return Client \ No newline at end of file diff --git a/src/sentry/core/context.tl b/src/sentry/core/context.tl deleted file mode 100644 index 74dc1e8..0000000 --- a/src/sentry/core/context.tl +++ /dev/null @@ -1,103 +0,0 @@ -local record Context - user: table - tags: {string: string} - extra: {string: any} - level: string - environment: string - release: string - breadcrumbs: {table} - max_breadcrumbs: number - contexts: {string: any} -- For runtime, os, device contexts -end - -function Context:new(): Context - return setmetatable({ - user = {}, - tags = {}, - extra = {}, - level = "error", - environment = "production", - release = nil, - breadcrumbs = {}, - max_breadcrumbs = 100, - contexts = {} - }, {__index = Context}) as Context -end - -function Context:set_user(user: table) - self.user = user or {} -end - -function Context:set_tag(key: string, value: string) - self.tags[key] = value -end - -function Context:set_extra(key: string, value: any) - self.extra[key] = value -end - -function Context:set_context(key: string, value: any) - self.contexts[key] = value -end - -function Context:set_level(level: string) - local valid_levels: {string: boolean} = {debug = true, info = true, warning = true, error = true, fatal = true} - if valid_levels[level] then - self.level = level - end -end - -function Context:add_breadcrumb(breadcrumb: table) - local crumb: table = { - timestamp = os.time(), - message = breadcrumb.message or "", - category = breadcrumb.category or "default", - level = breadcrumb.level or "info", - data = breadcrumb.data or {} - } - table.insert(self.breadcrumbs, crumb) - - while #self.breadcrumbs > self.max_breadcrumbs do - table.remove(self.breadcrumbs, 1) - end -end - -function Context:clear() - self.user = {} - self.tags = {} - self.extra = {} - self.breadcrumbs = {} - self.contexts = {} -end - -function Context:clone(): Context - local new_context = Context:new() - - for k, v in pairs(self.user) do - new_context.user[k] = v - end - - for k, v in pairs(self.tags) do - new_context.tags[k] = v - end - - for k, v in pairs(self.extra) do - new_context.extra[k] = v - end - - for k, v in pairs(self.contexts) do - new_context.contexts[k] = v - end - - new_context.level = self.level - new_context.environment = self.environment - new_context.release = self.release - - for i, breadcrumb in ipairs(self.breadcrumbs) do - new_context.breadcrumbs[i] = breadcrumb - end - - return new_context -end - -return Context \ No newline at end of file diff --git a/src/sentry/core/file_io.tl b/src/sentry/core/file_io.tl deleted file mode 100644 index a581ccb..0000000 --- a/src/sentry/core/file_io.tl +++ /dev/null @@ -1,67 +0,0 @@ -local record FileIO - write_file: function(self: FileIO, path: string, content: string): boolean, string - read_file: function(self: FileIO, path: string): string, string - file_exists: function(self: FileIO, path: string): boolean - ensure_directory: function(self: FileIO, path: string): boolean, string -end - -local record StandardFileIO -end - -function StandardFileIO:write_file(path: string, content: string): boolean, string - local file, err = io.open(path, "w") - if not file then - return false, "Failed to open file: " .. tostring(err) - end - - local success, write_err = file:write(content) - file:close() - - if not success then - return false, "Failed to write file: " .. tostring(write_err) - end - - return true, "File written successfully" -end - -function StandardFileIO:read_file(path: string): string, string - local file, err = io.open(path, "r") - if not file then - return "", "Failed to open file: " .. tostring(err) - end - - local content = file:read("*all") - file:close() - - return content or "", "" -end - -function StandardFileIO:file_exists(path: string): boolean - local file = io.open(path, "r") - if file then - file:close() - return true - end - return false -end - -function StandardFileIO:ensure_directory(path: string): boolean, string - local command = "mkdir -p " .. path - local success = os.execute(command) - - if success then - return true, "Directory created" - else - return false, "Failed to create directory" - end -end - -local function create_standard_file_io(): FileIO - return setmetatable({}, {__index = StandardFileIO}) as FileIO -end - -return { - FileIO = FileIO, - StandardFileIO = StandardFileIO, - create_standard_file_io = create_standard_file_io -} \ No newline at end of file diff --git a/src/sentry/core/file_transport.tl b/src/sentry/core/file_transport.tl deleted file mode 100644 index 9202dd2..0000000 --- a/src/sentry/core/file_transport.tl +++ /dev/null @@ -1,64 +0,0 @@ -local file_io = require("sentry.core.file_io") -local json = require("sentry.utils.json") -local version = require("sentry.version") - -local record FileTransport - endpoint: string - timeout: number - headers: {string: string} - file_path: string - file_io: file_io.FileIO - append_mode: boolean -end - -function FileTransport:send(event: table): boolean, string - local serialized = json.encode(event) - local timestamp = os.date("%Y-%m-%d %H:%M:%S") - local content = string.format("[%s] %s\n", timestamp, serialized) - - if self.append_mode and self.file_io:file_exists(self.file_path) then - local existing_content, read_err = self.file_io:read_file(self.file_path) - if read_err ~= "" then - return false, "Failed to read existing file: " .. read_err - end - content = existing_content .. content - end - - local success, err = self.file_io:write_file(self.file_path, content) - - if success then - return true, "Event written to file: " .. self.file_path - else - return false, "Failed to write event: " .. err - end -end - -function FileTransport:configure(config: table): FileTransport - self.endpoint = (config as any).dsn or "" - self.timeout = (config as any).timeout or 30 - self.file_path = (config as any).file_path or "sentry-events.log" - self.append_mode = (config as any).append_mode ~= false - - if (config as any).file_io then - self.file_io = (config as any).file_io - else - self.file_io = file_io.create_standard_file_io() - end - - local dir_path = self.file_path:match("^(.*/)") - if dir_path then - local dir_success, dir_err = self.file_io:ensure_directory(dir_path) - if not dir_success then - print("Warning: Failed to create directory: " .. dir_err) - end - end - - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-file/" .. version - } - - return self -end - -return FileTransport \ No newline at end of file diff --git a/src/sentry/core/scope.tl b/src/sentry/core/scope.tl deleted file mode 100644 index ab10f55..0000000 --- a/src/sentry/core/scope.tl +++ /dev/null @@ -1,166 +0,0 @@ --- Scope manages contextual data that gets applied to events --- This includes user, tags, extra data, contexts, breadcrumbs, etc. - -local record Scope - user: table - tags: {string: string} - extra: {string: any} - contexts: {string: any} -- For runtime, os, device contexts - breadcrumbs: {table} - max_breadcrumbs: number - level: string -end - -function Scope:new(): Scope - return setmetatable({ - user = {}, - tags = {}, - extra = {}, - contexts = {}, - breadcrumbs = {}, - max_breadcrumbs = 100, - level = nil -- nil means inherit from client/event - }, {__index = Scope}) as Scope -end - -function Scope:set_user(user: table) - self.user = user or {} -end - -function Scope:set_tag(key: string, value: string) - self.tags[key] = value -end - -function Scope:set_extra(key: string, value: any) - self.extra[key] = value -end - -function Scope:set_context(key: string, value: any) - self.contexts[key] = value -end - -function Scope:set_level(level: string) - local valid_levels: {string: boolean} = {debug = true, info = true, warning = true, error = true, fatal = true} - if valid_levels[level] then - self.level = level - end -end - -function Scope:add_breadcrumb(breadcrumb: table) - local crumb: table = { - timestamp = os.time(), - message = breadcrumb.message or "", - category = breadcrumb.category or "default", - level = breadcrumb.level or "info", - data = breadcrumb.data or {} - } - table.insert(self.breadcrumbs, crumb) - - while #self.breadcrumbs > self.max_breadcrumbs do - table.remove(self.breadcrumbs, 1) - end -end - -function Scope:clear() - self.user = {} - self.tags = {} - self.extra = {} - self.contexts = {} - self.breadcrumbs = {} - self.level = nil -end - -function Scope:clone(): Scope - local new_scope = Scope:new() - - for k, v in pairs(self.user) do - new_scope.user[k] = v - end - - for k, v in pairs(self.tags) do - new_scope.tags[k] = v - end - - for k, v in pairs(self.extra) do - new_scope.extra[k] = v - end - - for k, v in pairs(self.contexts) do - new_scope.contexts[k] = v - end - - new_scope.level = self.level - new_scope.max_breadcrumbs = self.max_breadcrumbs - - for i, breadcrumb in ipairs(self.breadcrumbs) do - new_scope.breadcrumbs[i] = breadcrumb - end - - return new_scope -end - --- Apply scope data to an event (used by Client when creating events) -function Scope:apply_to_event(event: table): table - -- Apply user data - if next(self.user) then - event.user = event.user or {} - for k, v in pairs(self.user) do - event.user[k] = v - end - end - - -- Apply tags - if next(self.tags) then - event.tags = event.tags or {} - for k, v in pairs(self.tags) do - event.tags[k] = v - end - end - - -- Apply extra data - if next(self.extra) then - event.extra = event.extra or {} - for k, v in pairs(self.extra) do - event.extra[k] = v - end - end - - -- Apply contexts - if next(self.contexts) then - event.contexts = event.contexts or {} - for k, v in pairs(self.contexts) do - event.contexts[k] = v - end - end - - -- Apply trace context if active (check for distributed tracing) - local success, tracing = pcall(require, "sentry.tracing.propagation") - if success and tracing and tracing.get_current_context then - local trace_context = tracing.get_current_context() - if trace_context then - event.contexts = event.contexts or {} - event.contexts.trace = { - trace_id = trace_context.trace_id, - span_id = trace_context.span_id, - parent_span_id = trace_context.parent_span_id - } - end - end - - -- Apply breadcrumbs - if #self.breadcrumbs > 0 then - event.breadcrumbs = {} - for i, breadcrumb in ipairs(self.breadcrumbs) do - event.breadcrumbs[i] = breadcrumb - end - end - - -- Apply level if set on scope - if self.level then - event.level = self.level - end - - return event -end - -return Scope \ No newline at end of file diff --git a/src/sentry/core/test_transport.tl b/src/sentry/core/test_transport.tl deleted file mode 100644 index 25d392d..0000000 --- a/src/sentry/core/test_transport.tl +++ /dev/null @@ -1,34 +0,0 @@ -local version = require("sentry.version") - -local record TestTransport - endpoint: string - timeout: number - headers: {string: string} - events: {table} -end - -function TestTransport:send(event: table): boolean, string - table.insert(self.events, event) - return true, "Event captured in test transport" -end - -function TestTransport:configure(config: table): TestTransport - self.endpoint = (config as any).dsn or "" - self.timeout = (config as any).timeout or 30 - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-test/" .. version - } - self.events = {} - return self -end - -function TestTransport:get_events(): {table} - return self.events -end - -function TestTransport:clear_events() - self.events = {} -end - -return TestTransport \ No newline at end of file diff --git a/src/sentry/core/transport.tl b/src/sentry/core/transport.tl deleted file mode 100644 index 037960b..0000000 --- a/src/sentry/core/transport.tl +++ /dev/null @@ -1,20 +0,0 @@ --- Platform-agnostic transport interface --- Load all platform-specific transport implementations -require("sentry.platforms.standard.transport") -require("sentry.platforms.standard.file_transport") -require("sentry.platforms.roblox.transport") -require("sentry.platforms.love2d.transport") -require("sentry.platforms.nginx.transport") -require("sentry.platforms.redis.transport") -require("sentry.platforms.defold.transport") -require("sentry.platforms.test.transport") - --- Re-export transport utilities -local transport_utils = require("sentry.utils.transport") - -return { - Transport = transport_utils.Transport, - create_transport = transport_utils.create_transport, - get_available_transports = transport_utils.get_available_transports, - register_transport_factory = transport_utils.register_transport_factory -} \ No newline at end of file diff --git a/src/sentry/init.tl b/src/sentry/init.tl deleted file mode 100644 index cf19087..0000000 --- a/src/sentry/init.tl +++ /dev/null @@ -1,150 +0,0 @@ -local Client = require("sentry.core.client") -local Scope = require("sentry.core.scope") - -local record Sentry - _client: Client - init: function(table): Client - capture_message: function(string, string): string - capture_exception: function(table, string): string - add_breadcrumb: function(table) - set_user: function(table) - set_tag: function(string, string) - set_extra: function(string, any) - flush: function() - close: function() - with_scope: function(function(Scope)) - wrap: function(function, function): boolean, any -end - -local sentry: Sentry = {} - -local function init(config: table): Client - if not config or not config.dsn then - error("Sentry DSN is required") - end - - sentry._client = Client:new(config as any) - return sentry._client -end - -local function capture_message(message: string, level: string): string - if not sentry._client then - error("Sentry not initialized. Call sentry.init() first.") - end - - return sentry._client:capture_message(message, level) -end - -local function capture_exception(exception: table, level: string): string - if not sentry._client then - error("Sentry not initialized. Call sentry.init() first.") - end - - return sentry._client:capture_exception(exception, level) -end - -local function add_breadcrumb(breadcrumb: table) - if sentry._client then - sentry._client:add_breadcrumb(breadcrumb) - end -end - -local function set_user(user: table) - if sentry._client then - sentry._client:set_user(user) - end -end - -local function set_tag(key: string, value: string) - if sentry._client then - sentry._client:set_tag(key, value) - end -end - -local function set_extra(key: string, value: any) - if sentry._client then - sentry._client:set_extra(key, value) - end -end - -local function flush() - if sentry._client and sentry._client.transport then - -- Call flush method if available - pcall(function() - (sentry._client.transport as any):flush() - end) - end -end - -local function close() - if sentry._client then - sentry._client:close() - sentry._client = nil - end -end - -local function with_scope(callback: function(scope: Scope)) - if not sentry._client then - error("Sentry not initialized. Call sentry.init() first.") - end - - local original_scope = sentry._client.scope:clone() - - local success, result = pcall(callback, sentry._client.scope) - - sentry._client.scope = original_scope - - if not success then - error(result as any) - end -end - -local function wrap(main_function: function, error_handler: function): boolean, any - if not sentry._client then - error("Sentry not initialized. Call sentry.init() first.") - end - - -- Default error handler that captures to Sentry - local function default_error_handler(err: any): any - -- Add breadcrumb for the unhandled error - add_breadcrumb({ - message = "Unhandled error occurred", - category = "error", - level = "error", - data = { - error_message = tostring(err) - } - }) - - -- Capture the exception with full context - capture_exception({ - type = "UnhandledException", - message = tostring(err) - }, "fatal") - - -- If user provided custom error handler, call it and return its result - if error_handler then - return error_handler(err) - end - - -- Return the error for the caller to handle (don't re-throw to avoid infinite loop) - return tostring(err) - end - - return xpcall(main_function, default_error_handler) -end - --- Set up the sentry module functions -sentry.init = init -sentry.capture_message = capture_message -sentry.capture_exception = capture_exception -sentry.add_breadcrumb = add_breadcrumb -sentry.set_user = set_user -sentry.set_tag = set_tag -sentry.set_extra = set_extra -sentry.flush = flush -sentry.close = close -sentry.with_scope = with_scope -sentry.wrap = wrap - -return sentry \ No newline at end of file diff --git a/src/sentry/logger/init.tl b/src/sentry/logger/init.tl deleted file mode 100644 index dba7fa3..0000000 --- a/src/sentry/logger/init.tl +++ /dev/null @@ -1,436 +0,0 @@ ----@class sentry.logger ---- Sentry logging module for structured log capture and transmission - -local json = require("sentry.utils.json") -local envelope = require("sentry.utils.envelope") -local utils = require("sentry.utils") - --- Type definitions for log records -local record LogLevel - trace: string - debug: string - info: string - warn: string - error: string - fatal: string -end - -local record Attribute - value: string | number | boolean | integer - type: string -end - -local record LogRecord - timestamp: number - trace_id: string - level: string - body: string - attributes: {string: Attribute} - severity_number: integer -end - -local record LogBuffer - logs: {LogRecord} - max_size: integer - flush_timeout: number - last_flush: number -end - -local record LoggerConfig - enable_logs: boolean - before_send_log: function(LogRecord): LogRecord - max_buffer_size: integer - flush_timeout: number - hook_print: boolean -end - --- Log level to severity number mapping -local LOG_LEVELS: LogLevel = { - trace = "trace", - debug = "debug", - info = "info", - warn = "warn", - error = "error", - fatal = "fatal" -} - -local SEVERITY_NUMBERS = { - trace = 1, - debug = 5, - info = 9, - warn = 13, - error = 17, - fatal = 21 -} - --- Module state -local logger = {} -local buffer: LogBuffer -local config: LoggerConfig -local original_print: function -local is_initialized = false - --- Initialize the logger with configuration -function logger.init(user_config: LoggerConfig) - config = { - enable_logs = user_config and user_config.enable_logs or false, - before_send_log = user_config and user_config.before_send_log, - max_buffer_size = user_config and user_config.max_buffer_size or 100, - flush_timeout = user_config and user_config.flush_timeout or 5.0, - hook_print = user_config and user_config.hook_print or false - } - - buffer = { - logs = {}, - max_size = config.max_buffer_size, - flush_timeout = config.flush_timeout, - last_flush = os.time() - } - - is_initialized = true - - -- Hook into print if requested - if config.hook_print then - logger.hook_print() - end -end - --- Get current trace context for log association -local function get_trace_context(): string, string - local success, tracing = pcall(require, "sentry.tracing") - if not success then - return utils.generate_uuid():gsub("-", ""), nil - end - - local trace_info = tracing.get_current_trace_info() - if trace_info and trace_info.trace_id then - return trace_info.trace_id, trace_info.span_id - end - - return utils.generate_uuid():gsub("-", ""), nil -end - --- Get default attributes for a log record -local function get_default_attributes(parent_span_id: string): {string: Attribute} - local attributes: {string: Attribute} = {} - - -- Get SDK info from central location - local version_success, version = pcall(require, "sentry.version") - local sdk_version = version_success and version or "unknown" - - attributes["sentry.sdk.name"] = {value = "sentry.lua", type = "string"} - attributes["sentry.sdk.version"] = {value = sdk_version, type = "string"} - - -- Get Sentry client context if available - local sentry_success, sentry = pcall(require, "sentry") - if sentry_success and sentry._client and sentry._client.config then - local client_config = sentry._client.config - - if client_config.environment then - attributes["sentry.environment"] = {value = client_config.environment, type = "string"} - end - - if client_config.release then - attributes["sentry.release"] = {value = client_config.release, type = "string"} - end - end - - -- Add parent span ID if available - if parent_span_id then - attributes["sentry.trace.parent_span_id"] = {value = parent_span_id, type = "string"} - end - - return attributes -end - --- Create a log record -local function create_log_record(level: string, body: string, template: string, params: {any}, extra_attributes: {string: any}): LogRecord - if not config.enable_logs then - return nil - end - - local trace_id, parent_span_id = get_trace_context() - local attributes = get_default_attributes(parent_span_id) - - -- Add template and parameters if provided - if template then - attributes["sentry.message.template"] = {value = template, type = "string"} - - if params then - for i, param in ipairs(params) do - local param_key = "sentry.message.parameter." .. tostring(i - 1) - local param_type = type(param) - - if param_type == "number" then - if math.floor(param) == param then - attributes[param_key] = {value = param, type = "integer"} - else - attributes[param_key] = {value = param, type = "double"} - end - elseif param_type == "boolean" then - attributes[param_key] = {value = param, type = "boolean"} - else - attributes[param_key] = {value = tostring(param), type = "string"} - end - end - end - end - - -- Add extra attributes - if extra_attributes then - for key, value in pairs(extra_attributes) do - local value_type = type(value) - if value_type == "number" then - if math.floor(value) == value then - attributes[key] = {value = value, type = "integer"} - else - attributes[key] = {value = value, type = "double"} - end - elseif value_type == "boolean" then - attributes[key] = {value = value, type = "boolean"} - else - attributes[key] = {value = tostring(value), type = "string"} - end - end - end - - local record: LogRecord = { - timestamp = os.time() + (os.clock() % 1), - trace_id = trace_id, - level = level, - body = body, - attributes = attributes, - severity_number = SEVERITY_NUMBERS[level] or 9 - } - - return record -end - --- Add a log record to the buffer -local function add_to_buffer(record: LogRecord) - if not record or not buffer then - return - end - - -- Apply before_send_log hook if configured - if config.before_send_log then - record = config.before_send_log(record) - if not record then - return -- Log was filtered out - end - end - - table.insert(buffer.logs, record) - - -- Check if we need to flush - local should_flush = false - - if #buffer.logs >= buffer.max_size then - should_flush = true - elseif buffer.flush_timeout > 0 then - local current_time = os.time() - if (current_time - buffer.last_flush) >= buffer.flush_timeout then - should_flush = true - end - end - - if should_flush then - logger.flush() - end -end - --- Flush the log buffer -function logger.flush() - if not buffer or #buffer.logs == 0 then - return - end - - -- Get Sentry client - local sentry_success, sentry = pcall(require, "sentry") - if not sentry_success or not sentry._client or not sentry._client.transport then - -- Clear buffer even if we can't send - buffer.logs = {} - buffer.last_flush = os.time() - return - end - - -- Get version from central location - local version_success, version = pcall(require, "sentry.version") - local sdk_version = version_success and version or "unknown" - - -- Build log envelope using the proper log format - local envelope_body = envelope.build_log_envelope(buffer.logs) - - -- Send via envelope transport only (no fallback to /store endpoint) - if sentry._client.transport.send_envelope then - local success, err = sentry._client.transport:send_envelope(envelope_body) - if success then - print("[Sentry] Sent " .. #buffer.logs .. " log records via envelope") - else - print("[Sentry] Failed to send log envelope: " .. tostring(err)) - end - else - print("[Sentry] No envelope transport available for logs") - end - - -- Clear buffer - buffer.logs = {} - buffer.last_flush = os.time() -end - --- Core logging function -local function log(level: string, message: string, template: string, params: {any}, attributes: {string: any}) - if not is_initialized or not config.enable_logs then - return - end - - local record = create_log_record(level, message, template, params, attributes) - if record then - add_to_buffer(record) - end -end - --- String formatting helper (similar to printf) -local function format_message(template: string, ...: any): string, {any} - local args = {...} - local formatted = template - - -- Simple %s replacement - local i = 1 - formatted = formatted:gsub("%%s", function() - local arg = args[i] - i = i + 1 - return tostring(arg or "") - end) - - return formatted, args -end - --- Public logging API functions -function logger.trace(message: string, params: {any}, attributes: {string: any}) - if type(message) == "string" and message:find("%%s") and params then - local formatted, args = format_message(message, table.unpack(params)) - log("trace", formatted, message, args, attributes) - else - log("trace", message, nil, nil, attributes or params) - end -end - -function logger.debug(message: string, params: {any}, attributes: {string: any}) - if type(message) == "string" and message:find("%%s") and params then - local formatted, args = format_message(message, table.unpack(params)) - log("debug", formatted, message, args, attributes) - else - log("debug", message, nil, nil, attributes or params) - end -end - -function logger.info(message: string, params: {any}, attributes: {string: any}) - if type(message) == "string" and message:find("%%s") and params then - local formatted, args = format_message(message, table.unpack(params)) - log("info", formatted, message, args, attributes) - else - log("info", message, nil, nil, attributes or params) - end -end - -function logger.warn(message: string, params: {any}, attributes: {string: any}) - if type(message) == "string" and message:find("%%s") and params then - local formatted, args = format_message(message, table.unpack(params)) - log("warn", formatted, message, args, attributes) - else - log("warn", message, nil, nil, attributes or params) - end -end - -function logger.error(message: string, params: {any}, attributes: {string: any}) - if type(message) == "string" and message:find("%%s") and params then - local formatted, args = format_message(message, table.unpack(params)) - log("error", formatted, message, args, attributes) - else - log("error", message, nil, nil, attributes or params) - end -end - -function logger.fatal(message: string, params: {any}, attributes: {string: any}) - if type(message) == "string" and message:find("%%s") and params then - local formatted, args = format_message(message, table.unpack(params)) - log("fatal", formatted, message, args, attributes) - else - log("fatal", message, nil, nil, attributes or params) - end -end - --- Print hooking functionality -function logger.hook_print() - if original_print then - return -- Already hooked - end - - original_print = print - - -- Flag to prevent recursion - local in_sentry_print = false - - _G.print = function(...: any) - -- Call original print first - original_print(...) - - -- Avoid recursion from Sentry's own print statements - if in_sentry_print then - return - end - - if not is_initialized or not config.enable_logs then - return - end - - in_sentry_print = true - - -- Convert arguments to string - local args = {...} - local parts = {} - for i, arg in ipairs(args) do - parts[i] = tostring(arg) - end - local message = table.concat(parts, "\t") - - -- Create log record with sentry.origin - local record = create_log_record("info", message, nil, nil, { - ["sentry.origin"] = "auto.logging.print" - }) - - if record then - add_to_buffer(record) - end - - in_sentry_print = false - end -end - -function logger.unhook_print() - if original_print then - _G.print = original_print - original_print = nil - end -end - --- Get current configuration -function logger.get_config(): LoggerConfig - return config -end - --- Get buffer status for debugging -function logger.get_buffer_status(): {string: any} - if not buffer then - return {logs = 0, max_size = 0, last_flush = 0} - end - - return { - logs = #buffer.logs, - max_size = buffer.max_size, - last_flush = buffer.last_flush - } -end - -return logger \ No newline at end of file diff --git a/src/sentry/performance/init.tl b/src/sentry/performance/init.tl deleted file mode 100644 index 1b596ca..0000000 --- a/src/sentry/performance/init.tl +++ /dev/null @@ -1,352 +0,0 @@ ----@class sentry.performance ---- Object-oriented performance monitoring without global state - -local headers = require("sentry.tracing.headers") - -local record Transaction - span_id: string - parent_span_id: string - trace_id: string - op: string - description: string - status: string - start_timestamp: number - timestamp: number - - -- Transaction-specific fields - event_id: string - type: string - transaction: string - spans: {Span} - contexts: {string: any} - tags: {string: string} - extra: {string: any} - finished: boolean - active_spans: {Span} -- Stack for child spans - - -- Methods - finish: function(self: Transaction, status: string) - start_span: function(self: Transaction, op: string, description: string, options: table): Span - add_tag: function(self: Transaction, key: string, value: string) - add_data: function(self: Transaction, key: string, value: any) -end - -local record Span - span_id: string - parent_span_id: string - trace_id: string - op: string - description: string - status: string - tags: {string: string} - data: {string: any} - start_timestamp: number - timestamp: number - origin: string - finished: boolean - transaction: Transaction -- Reference to parent transaction - - -- Methods - finish: function(self: Span, status: string) - start_span: function(self: Span, op: string, description: string, options: table): Span - add_tag: function(self: Span, key: string, value: string) - add_data: function(self: Span, key: string, value: any) -end - -local performance = {} - ----Generate a high-precision timestamp ----@return number timestamp Unix timestamp with microsecond precision -local function get_timestamp(): number - return os.time() + (os.clock() % 1) -end - ----Get SDK version from central location ----@return string version SDK version or "unknown" -local function get_sdk_version(): string - local success, version = pcall(require, "sentry.version") - return success and version or "unknown" -end - ----Span methods (defined first since transaction methods reference it) -local span_mt = {} -span_mt.__index = span_mt - ----Transaction methods -local transaction_mt = {} -transaction_mt.__index = transaction_mt - -function transaction_mt:finish(status: string) - if self.finished then - return - end - - self.timestamp = get_timestamp() - self.status = status or "ok" - self.finished = true - - -- Send transaction to Sentry using the main client - local sentry = require("sentry") - - local transaction_data = { - event_id = self.event_id, - type = "transaction", - transaction = self.transaction, - start_timestamp = self.start_timestamp, - timestamp = self.timestamp, - spans = self.spans, - contexts = self.contexts, - tags = self.tags, - extra = self.extra, - platform = "lua", - sdk = { - name = "sentry.lua", - version = get_sdk_version() - } - } - - -- Add trace context - transaction_data.contexts = transaction_data.contexts or {} - transaction_data.contexts.trace = { - trace_id = self.trace_id, - span_id = self.span_id, - parent_span_id = self.parent_span_id, - op = self.op, - status = self.status - } - - -- Use the main sentry client to send the transaction - if sentry._client then - local envelope = require("sentry.utils.envelope") - if sentry._client.transport and sentry._client.transport.send_envelope then - local envelope_data = envelope.build_transaction_envelope(transaction_data, self.event_id) - local transport_success, err = sentry._client.transport:send_envelope(envelope_data) - - if transport_success then - print("[Sentry] Transaction sent: " .. self.event_id) - else - print("[Sentry] Failed to send transaction: " .. tostring(err)) - end - end - end -end - -function transaction_mt:start_span(op: string, description: string, options: table): Span - options = options or {} - - local parent_span_id = #self.active_spans > 0 and self.active_spans[#self.active_spans].span_id or self.span_id - - local span_data: Span = { - span_id = headers.generate_span_id(), - parent_span_id = parent_span_id, - trace_id = self.trace_id, - op = op, - description = description, - status = "ok", - tags = options.tags or {}, - data = options.data or {}, - start_timestamp = get_timestamp(), - timestamp = 0, -- Will be set when finished - origin = options.origin or "manual", - finished = false, - transaction = self - } - - -- Set up span methods - setmetatable(span_data, span_mt) - - table.insert(self.active_spans, span_data) - - -- Update propagation context to reflect the current active span - local success, propagation = pcall(require, "sentry.tracing.propagation") - if success then - local propagation_context = { - trace_id = span_data.trace_id, - span_id = span_data.span_id, - parent_span_id = span_data.parent_span_id, - sampled = true, - baggage = {}, - dynamic_sampling_context = {} - } - propagation.set_current_context(propagation_context) - end - - return span_data -end - -function transaction_mt:add_tag(key: string, value: string) - self.tags = self.tags or {} - self.tags[key] = value -end - -function transaction_mt:add_data(key: string, value: any) - self.extra = self.extra or {} - self.extra[key] = value -end - - -function span_mt:finish(status: string) - if self.finished then - return - end - - self.timestamp = get_timestamp() - self.status = status or "ok" - self.finished = true - - -- Remove from active spans stack - local tx = self.transaction - for i = #tx.active_spans, 1, -1 do - if tx.active_spans[i].span_id == self.span_id then - table.remove(tx.active_spans, i) - break - end - end - - -- Add to completed spans - table.insert(tx.spans, { - span_id = self.span_id, - parent_span_id = self.parent_span_id, - trace_id = self.trace_id, - op = self.op, - description = self.description, - status = self.status, - tags = self.tags, - data = self.data, - start_timestamp = self.start_timestamp, - timestamp = self.timestamp, - origin = self.origin - }) - - -- Update propagation context (revert to parent context) - local success, propagation = pcall(require, "sentry.tracing.propagation") - if success then - local parent_context - if #tx.active_spans > 0 then - -- Still have active spans, use the most recent one - local active_span = tx.active_spans[#tx.active_spans] - parent_context = { - trace_id = active_span.trace_id, - span_id = active_span.span_id, - parent_span_id = active_span.parent_span_id, - sampled = true, - baggage = {}, - dynamic_sampling_context = {} - } - else - -- No active spans, revert to transaction context - parent_context = { - trace_id = tx.trace_id, - span_id = tx.span_id, - parent_span_id = tx.parent_span_id, - sampled = true, - baggage = {}, - dynamic_sampling_context = {} - } - end - propagation.set_current_context(parent_context) - end -end - -function span_mt:start_span(op: string, description: string, options: table): Span - -- Delegate to transaction - return self.transaction:start_span(op, description, options) -end - -function span_mt:add_tag(key: string, value: string) - self.tags = self.tags or {} - self.tags[key] = value -end - -function span_mt:add_data(key: string, value: any) - self.data = self.data or {} - self.data[key] = value -end - ----Start a new transaction ----@param name string Transaction name ----@param op string Operation type (e.g., "http.server", "navigation") ----@param options table? Additional options ----@return Transaction transaction The started transaction -function performance.start_transaction(name: string, op: string, options: table): Transaction - options = options or {} - - -- Check for existing propagation context first - local trace_id = options.trace_id - local parent_span_id = options.parent_span_id - local span_id = options.span_id - - if not trace_id or not span_id then - local success, propagation = pcall(require, "sentry.tracing.propagation") - if success then - local context = propagation.get_current_context() - if context then - -- Continue existing trace - trace_id = trace_id or context.trace_id - parent_span_id = parent_span_id or context.span_id -- Current context becomes parent - span_id = span_id or headers.generate_span_id() -- Generate new span for transaction - else - -- No context exists, create new trace context automatically - context = propagation.start_new_trace() - trace_id = trace_id or context.trace_id - span_id = span_id or headers.generate_span_id() - end - end - end - - -- Fallback to generating new trace if no context exists - trace_id = trace_id or headers.generate_trace_id() - span_id = span_id or headers.generate_span_id() - local start_time = get_timestamp() - - local transaction: Transaction = { - event_id = require("sentry.utils").generate_uuid(), - type = "transaction", - transaction = name, - start_timestamp = start_time, - timestamp = start_time, -- Will be updated when finished - spans = {}, - contexts = { - trace = { - trace_id = trace_id, - span_id = span_id, - parent_span_id = parent_span_id, - op = op, - status = "unknown" - } - }, - tags = options.tags or {}, - extra = options.extra or {}, - - -- Span fields - span_id = span_id, - parent_span_id = parent_span_id, - trace_id = trace_id, - op = op, - description = name, - status = "ok", - finished = false, - active_spans = {} - } - - -- Set up transaction methods - setmetatable(transaction, transaction_mt) - - -- Update propagation context to the transaction - local success, propagation = pcall(require, "sentry.tracing.propagation") - if success then - local propagation_context = { - trace_id = transaction.trace_id, - span_id = transaction.span_id, - parent_span_id = transaction.parent_span_id, - sampled = true, - baggage = {}, - dynamic_sampling_context = {} - } - propagation.set_current_context(propagation_context) - end - - return transaction -end - -return performance \ No newline at end of file diff --git a/src/sentry/platform_loader.tl b/src/sentry/platform_loader.tl deleted file mode 100644 index 5e4fc2d..0000000 --- a/src/sentry/platform_loader.tl +++ /dev/null @@ -1,22 +0,0 @@ --- Platform loader: automatically loads available platform detectors --- This file tries to load platform-specific modules that register themselves - -local function load_platforms() - local platform_modules = { - "sentry.platforms.standard.os_detection", - "sentry.platforms.roblox.os_detection", - "sentry.platforms.love2d.os_detection", - "sentry.platforms.nginx.os_detection" - } - - for _, module_name in ipairs(platform_modules) do - pcall(require, module_name) - end -end - --- Load platforms immediately -load_platforms() - -return { - load_platforms = load_platforms -} \ No newline at end of file diff --git a/src/sentry/platforms/defold/file_io.tl b/src/sentry/platforms/defold/file_io.tl deleted file mode 100644 index a917596..0000000 --- a/src/sentry/platforms/defold/file_io.tl +++ /dev/null @@ -1,77 +0,0 @@ -local file_io = require("sentry.core.file_io") - -local record DefoldFileIO -end - -function DefoldFileIO:write_file(path: string, content: string): boolean, string - if not sys then - return false, "Defold sys module not available" - end - - local success, err = pcall(function() - local save_path = sys.get_save_file("sentry", path) - local file = io.open(save_path, "w") - if file then - file:write(content) - file:close() - else - error("Failed to open file for writing") - end - end) - - if success then - return true, "Event written to Defold save file" - else - return false, "Defold file error: " .. tostring(err) - end -end - -function DefoldFileIO:read_file(path: string): string, string - if not sys then - return "", "Defold sys module not available" - end - - local success, result = pcall(function() - local save_path = sys.get_save_file("sentry", path) - local file = io.open(save_path, "r") - if file then - local content = file:read("*all") - file:close() - return content - end - return "" - end) - - if success then - return result or "", "" - else - return "", "Failed to read Defold file: " .. tostring(result) - end -end - -function DefoldFileIO:file_exists(path: string): boolean - if not sys then - return false - end - - local save_path = sys.get_save_file("sentry", path) - local file = io.open(save_path, "r") - if file then - file:close() - return true - end - return false -end - -function DefoldFileIO:ensure_directory(path: string): boolean, string - return true, "Defold handles save directories automatically" -end - -local function create_defold_file_io(): file_io.FileIO - return setmetatable({}, {__index = DefoldFileIO}) as file_io.FileIO -end - -return { - DefoldFileIO = DefoldFileIO, - create_defold_file_io = create_defold_file_io -} \ No newline at end of file diff --git a/src/sentry/platforms/defold/transport.tl b/src/sentry/platforms/defold/transport.tl deleted file mode 100644 index ecb6633..0000000 --- a/src/sentry/platforms/defold/transport.tl +++ /dev/null @@ -1,71 +0,0 @@ --- Defold transport (similar to file transport but optimized for Defold) -local transport_utils = require("sentry.utils.transport") -local json = require("sentry.utils.json") -local version = require("sentry.version") - -local record DefoldTransport - endpoint: string - timeout: number - headers: {string: string} - event_queue: {table} -end - -function DefoldTransport:send(event: table): boolean, string - -- In Defold, we typically queue events and process them later - table.insert(self.event_queue, event) - - return true, "Event queued for Defold processing" -end - -function DefoldTransport:configure(config: table): transport_utils.Transport - self.endpoint = (config as any).dsn or "" - self.timeout = (config as any).timeout or 30 - self.event_queue = {} - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-defold/" .. version - } - return self as transport_utils.Transport -end - --- Flush queued events (call this from your Defold script) -function DefoldTransport:flush() - if #self.event_queue == 0 then - return - end - - for _, event in ipairs(self.event_queue) do - local body = json.encode(event) - -- In Defold, you would implement HTTP requests or file writing here - print("[Sentry] Would send event: " .. ((event as any).event_id or "unknown")) - end - - self.event_queue = {} -end - --- Create factory function -local function create_defold_transport(config: table): transport_utils.Transport - local transport = DefoldTransport - return transport:configure(config) -end - --- Check if Defold transport is available (basic check) -local function is_defold_available(): boolean - -- We could check for Defold-specific globals here if available - -- For now, we'll assume it's always available as a fallback - return false -- Set to false so it doesn't interfere with other transports -end - --- Register this transport factory with low priority -transport_utils.register_transport_factory({ - name = "defold", - priority = 50, -- Lower priority - specialized use - create = create_defold_transport, - is_available = is_defold_available -}) - -return { - DefoldTransport = DefoldTransport, - create_defold_transport = create_defold_transport, - is_defold_available = is_defold_available -} \ No newline at end of file diff --git a/src/sentry/platforms/love2d/context.tl b/src/sentry/platforms/love2d/context.tl deleted file mode 100644 index e96fa16..0000000 --- a/src/sentry/platforms/love2d/context.tl +++ /dev/null @@ -1,27 +0,0 @@ --- LÖVE 2D specific context information -local function get_love2d_context(): table - local context = {} - - if _G.love then - local love = _G.love as any - context.love_version = love.getVersion and table.concat({love.getVersion()}, ".") or "unknown" - - if love.graphics then - local w, h = love.graphics.getDimensions() - context.screen = { - width = w, - height = h - } - end - - if love.system then - context.os = love.system.getOS() - end - end - - return context -end - -return { - get_love2d_context = get_love2d_context -} \ No newline at end of file diff --git a/src/sentry/platforms/love2d/integration.tl b/src/sentry/platforms/love2d/integration.tl deleted file mode 100644 index c112f49..0000000 --- a/src/sentry/platforms/love2d/integration.tl +++ /dev/null @@ -1,174 +0,0 @@ --- Love2D Integration with error handler hooking -local transport_utils = require("sentry.utils.transport") - -local record Love2DIntegration - transport: transport_utils.Transport - original_errorhandler: function | nil - sentry_client: any - - configure: function(Love2DIntegration, table): transport_utils.Transport - install_error_handler: function(Love2DIntegration, any) - uninstall_error_handler: function(Love2DIntegration) -end - --- Hook into love.errorhandler to capture fatal errors -local function hook_error_handler(client: any): function, function - local original_errorhandler = (_G as any).love and (_G as any).love.errorhandler - - local function sentry_errorhandler(msg: string): any - -- Capture the error in Sentry as a fatal exception - if client then - -- Create exception mechanism with handled: false (fatal) - local exception: {string: any} = { - type = "RuntimeError", - value = tostring(msg), - mechanism = { - type = "love.errorhandler", - handled = false, - synthetic = false - } - } - - -- Add stacktrace - local stacktrace = debug.traceback(msg, 2) - if stacktrace then - exception.stacktrace = { - frames = {} -- TODO: Parse stacktrace frames if needed - } - end - - -- Create Sentry event - local event: {string: any} = { - level = "fatal", - exception = { - values = {exception} - }, - extra = { - error_message = tostring(msg), - love_errorhandler = true - } - } - - -- Create a proper Sentry event with mechanism.handled: false - local stacktrace = require("sentry.utils.stacktrace") - local serialize = require("sentry.utils.serialize") - local stack_trace = stacktrace.get_stack_trace(2) - - -- Create event with proper exception mechanism - local event = serialize.create_event("fatal", tostring(msg), - client.options.environment or "production", - client.options.release, stack_trace) - - -- Add exception with mechanism.handled: false - event.exception = { - values = {{ - type = "RuntimeError", - value = tostring(msg), - mechanism = { - type = "love.errorhandler", - handled = false, - synthetic = false - }, - stacktrace = stack_trace - }} - } - - -- Apply scope data to event - event = client.scope:apply_to_event(event) - - -- Apply before_send hook if configured - if client.options.before_send then - event = client.options.before_send(event) - if not event then - return -- Event was filtered out - end - end - - -- Send event directly via transport - if client.transport then - local success, err = client.transport:send(event) - if client.options.debug then - if success then - print("[Sentry] Fatal error sent: " .. event.event_id) - else - print("[Sentry] Failed to send fatal error: " .. tostring(err)) - end - end - - -- Force immediate flush to ensure error is sent before app crashes - if client.transport.flush then - client.transport:flush() - end - end - end - - -- Call original error handler if it exists, or create default behavior - if original_errorhandler then - local ok, result = xpcall(original_errorhandler, debug.traceback, msg) - if ok then - return result - else - -- Original error handler failed, create minimal error screen - print("Error in original love.errorhandler:", result) - end - end - - -- Default error handling behavior (Love2D will crash after this) - print("Fatal error:", msg) - print(debug.traceback()) - - -- Re-throw the error to ensure Love2D crashes as expected - error(msg) - end - - return sentry_errorhandler, original_errorhandler -end - --- Setup Love2D integration -local function setup_love2d_integration(): Love2DIntegration - local love2d_transport = require("sentry.platforms.love2d.transport") - - if not love2d_transport.is_love2d_available() then - error("Love2D integration can only be used in Love2D environment") - end - - local integration: Love2DIntegration = {} as Love2DIntegration - integration.transport = nil - integration.original_errorhandler = nil - integration.sentry_client = nil - - function integration:configure(config: table): transport_utils.Transport - self.transport = love2d_transport.create_love2d_transport(config) - return self.transport - end - - function integration:install_error_handler(client: any) - if not (_G as any).love then - return - end - - self.sentry_client = client - local sentry_handler, original = hook_error_handler(client) - self.original_errorhandler = original - - -- Install our error handler - (_G as any).love.errorhandler = sentry_handler - - print("✅ Love2D error handler integration installed") - end - - function integration:uninstall_error_handler() - if (_G as any).love and self.original_errorhandler then - (_G as any).love.errorhandler = self.original_errorhandler - self.original_errorhandler = nil - print("✅ Love2D error handler integration uninstalled") - end - end - - return integration -end - -return { - setup_love2d_integration = setup_love2d_integration, - hook_error_handler = hook_error_handler -} \ No newline at end of file diff --git a/src/sentry/platforms/love2d/os_detection.tl b/src/sentry/platforms/love2d/os_detection.tl deleted file mode 100644 index 55812a9..0000000 --- a/src/sentry/platforms/love2d/os_detection.tl +++ /dev/null @@ -1,24 +0,0 @@ -local os_utils = require("sentry.utils.os") -local OSInfo = os_utils.OSInfo - -local function detect_os(): OSInfo - if _G.love and (_G.love as any).system then - local os_name = (_G.love as any).system.getOS() - if os_name then - return { - name = os_name, - version = nil - } - end - end - return nil -end - --- Register this detector -os_utils.register_detector({ - detect = detect_os -}) - -return { - detect_os = detect_os -} \ No newline at end of file diff --git a/src/sentry/platforms/love2d/transport.tl b/src/sentry/platforms/love2d/transport.tl deleted file mode 100644 index 923cbe9..0000000 --- a/src/sentry/platforms/love2d/transport.tl +++ /dev/null @@ -1,159 +0,0 @@ --- LÖVE 2D transport with lua-https support -local transport_utils = require("sentry.utils.transport") -local json = require("sentry.utils.json") -local version = require("sentry.version") -local dsn_utils = require("sentry.utils.dsn") - -local record Love2DTransport - endpoint: string - envelope_endpoint: string - timeout: number - headers: {string: string} - envelope_headers: {string: string} - event_queue: {table} - envelope_queue: {string} - dsn_info: any - send: function(self: Love2DTransport, event: table): boolean, string - send_envelope: function(self: Love2DTransport, envelope_body: string): boolean, string - configure: function(self: Love2DTransport, config: table): transport_utils.Transport - flush: function(self: Love2DTransport) - close: function(self: Love2DTransport) -end - -function Love2DTransport:send(event: table): boolean, string - -- Check if we're in LÖVE 2D environment - local love_global = rawget(_G, "love") - if not love_global then - return false, "Not in LÖVE 2D environment" - end - - -- Queue the event for processing - table.insert(self.event_queue, event) - - -- Process immediately in Love2D main thread - self:flush() - - return true, "Event queued for sending in LÖVE 2D" -end - -function Love2DTransport:send_envelope(envelope_body: string): boolean, string - -- Check if we're in LÖVE 2D environment - local love_global = rawget(_G, "love") - if not love_global then - return false, "Not in LÖVE 2D environment" - end - - -- Queue the envelope for processing - table.insert(self.envelope_queue, envelope_body) - - -- Process immediately in Love2D main thread - self:flush() - - return true, "Envelope queued for sending in LÖVE 2D" -end - -function Love2DTransport:configure(config: table): transport_utils.Transport - local dsn = (config as any).dsn or "" - self.dsn_info = dsn_utils.parse_dsn(dsn) - self.endpoint = dsn_utils.build_ingest_url(self.dsn_info) - self.envelope_endpoint = dsn_utils.build_envelope_url(self.dsn_info) - self.timeout = (config as any).timeout or 30 - self.event_queue = {} - self.envelope_queue = {} - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-love2d/" .. version, - ["X-Sentry-Auth"] = dsn_utils.build_auth_header(self.dsn_info) - } - self.envelope_headers = { - ["Content-Type"] = "application/x-sentry-envelope", - ["User-Agent"] = "sentry-lua-love2d/" .. version, - ["X-Sentry-Auth"] = dsn_utils.build_auth_header(self.dsn_info) - } - - return self as transport_utils.Transport -end - --- Process queued events and envelopes using lua-https -function Love2DTransport:flush() - local love_global = rawget(_G, "love") - if not love_global then - return - end - - -- Try to load lua-https module - local https_ok, https = pcall(require, "https") - if not https_ok then - print("[Sentry] lua-https module not available: " .. tostring(https)) - return - end - - -- Send queued events - if #self.event_queue > 0 then - for _, event in ipairs(self.event_queue) do - local body = json.encode(event) - - local status = https.request(self.endpoint, { - method = "POST", - headers = self.headers, - data = body - }) - - if status == 200 then - print("[Sentry] Event sent successfully (status: " .. status .. ")") - else - print("[Sentry] Event send failed to " .. self.endpoint .. " (status: " .. tostring(status) .. ")") - end - end - self.event_queue = {} - end - - -- Send queued envelopes - if #self.envelope_queue > 0 then - for _, envelope_body in ipairs(self.envelope_queue) do - local status = https.request(self.envelope_endpoint, { - method = "POST", - headers = self.envelope_headers, - data = envelope_body - }) - - if status == 200 then - print("[Sentry] Envelope sent successfully (status: " .. status .. ")") - else - print("[Sentry] Envelope send failed to " .. self.envelope_endpoint .. " (status: " .. tostring(status) .. ")") - end - end - self.envelope_queue = {} - end -end - --- Clean shutdown -function Love2DTransport:close() - -- Final flush before closing - self:flush() -end - --- Create factory function -local function create_love2d_transport(config: table): transport_utils.Transport - local transport = Love2DTransport - return transport:configure(config) -end - --- Check if LÖVE 2D transport is available -local function is_love2d_available(): boolean - return rawget(_G, "love") ~= nil -end - --- Register this transport factory with high priority for LÖVE 2D -transport_utils.register_transport_factory({ - name = "love2d", - priority = 180, -- High priority in LÖVE 2D environment - create = create_love2d_transport, - is_available = is_love2d_available -}) - -return { - Love2DTransport = Love2DTransport, - create_love2d_transport = create_love2d_transport, - is_love2d_available = is_love2d_available -} \ No newline at end of file diff --git a/src/sentry/platforms/nginx/os_detection.tl b/src/sentry/platforms/nginx/os_detection.tl deleted file mode 100644 index a4b275c..0000000 --- a/src/sentry/platforms/nginx/os_detection.tl +++ /dev/null @@ -1,36 +0,0 @@ -local os_utils = require("sentry.utils.os") -local OSInfo = os_utils.OSInfo - -local function detect_os(): OSInfo - if _G.ngx then - -- OpenResty/nginx runs on Linux typically, try to get OS info - local handle = io.popen("uname -s 2>/dev/null") - if handle then - local name = handle:read("*a") - handle:close() - if name then - name = name:gsub("\n", "") - local handle_version = io.popen("uname -r 2>/dev/null") - if handle_version then - local version = handle_version:read("*a") - handle_version:close() - version = version and version:gsub("\n", "") or "" - return { - name = name, - version = version - } - end - end - end - end - return nil -end - --- Register this detector -os_utils.register_detector({ - detect = detect_os -}) - -return { - detect_os = detect_os -} \ No newline at end of file diff --git a/src/sentry/platforms/nginx/transport.tl b/src/sentry/platforms/nginx/transport.tl deleted file mode 100644 index 127a69a..0000000 --- a/src/sentry/platforms/nginx/transport.tl +++ /dev/null @@ -1,75 +0,0 @@ --- Nginx/OpenResty transport using resty.http -local transport_utils = require("sentry.utils.transport") -local dsn_utils = require("sentry.utils.dsn") -local json = require("sentry.utils.json") -local http = require("sentry.utils.http") -local version = require("sentry.version") - -local record NginxTransport - endpoint: string - timeout: number - headers: {string: string} -end - -function NginxTransport:send(event: table): boolean, string - local body = json.encode(event) - - local request = { - url = self.endpoint, - method = "POST", - headers = self.headers, - body = body, - timeout = self.timeout - } - - local response = http.request(request) - - if response.success and response.status == 200 then - return true, "Event sent successfully" - else - local error_msg = response.error or "HTTP error: " .. tostring(response.status) - return false, error_msg - end -end - -function NginxTransport:configure(config: table): transport_utils.Transport - local dsn, err = dsn_utils.parse_dsn((config as any).dsn or "") - if err then - error("Invalid DSN: " .. err) - end - - self.dsn = dsn - self.endpoint = dsn_utils.build_ingest_url(dsn) - self.timeout = (config as any).timeout or 30 - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-nginx/" .. version, - ["X-Sentry-Auth"] = dsn_utils.build_auth_header(dsn) - } - return self as transport_utils.Transport -end - --- Create factory function -local function create_nginx_transport(config: table): transport_utils.Transport - local transport = NginxTransport - return transport:configure(config) -end - --- Check if nginx transport is available -local function is_nginx_available(): boolean - return _G.ngx ~= nil -end - --- Register this transport factory with high priority for nginx -transport_utils.register_transport_factory({ - name = "nginx", - priority = 190, -- High priority in nginx/OpenResty environment - create = create_nginx_transport, - is_available = is_nginx_available -}) - -return { - NginxTransport = NginxTransport, - create_nginx_transport = create_nginx_transport, - is_nginx_available = is_nginx_available -} \ No newline at end of file diff --git a/src/sentry/platforms/redis/transport.tl b/src/sentry/platforms/redis/transport.tl deleted file mode 100644 index 1ccc963..0000000 --- a/src/sentry/platforms/redis/transport.tl +++ /dev/null @@ -1,66 +0,0 @@ --- Redis transport for queuing events -local transport_utils = require("sentry.utils.transport") -local json = require("sentry.utils.json") -local version = require("sentry.version") - -local record RedisTransport - endpoint: string - timeout: number - headers: {string: string} - redis_key: string -end - -function RedisTransport:send(event: table): boolean, string - if not _G.redis then - return false, "Redis not available in this environment" - end - - local body = json.encode(event) - - local success, err = pcall(function() - (_G.redis as any).call("LPUSH", self.redis_key or "sentry:events", body) - (_G.redis as any).call("LTRIM", self.redis_key or "sentry:events", 0, 999) - end) - - if success then - return true, "Event queued in Redis" - else - return false, "Redis error: " .. tostring(err) - end -end - -function RedisTransport:configure(config: table): transport_utils.Transport - self.endpoint = (config as any).dsn or "" - self.timeout = (config as any).timeout or 30 - self.redis_key = (config as any).redis_key or "sentry:events" - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-redis/" .. version - } - return self as transport_utils.Transport -end - --- Create factory function -local function create_redis_transport(config: table): transport_utils.Transport - local transport = RedisTransport - return transport:configure(config) -end - --- Check if redis transport is available -local function is_redis_available(): boolean - return _G.redis ~= nil -end - --- Register this transport factory with medium priority -transport_utils.register_transport_factory({ - name = "redis", - priority = 150, -- Medium priority for Redis environments - create = create_redis_transport, - is_available = is_redis_available -}) - -return { - RedisTransport = RedisTransport, - create_redis_transport = create_redis_transport, - is_redis_available = is_redis_available -} \ No newline at end of file diff --git a/src/sentry/platforms/roblox/context.tl b/src/sentry/platforms/roblox/context.tl deleted file mode 100644 index 7770413..0000000 --- a/src/sentry/platforms/roblox/context.tl +++ /dev/null @@ -1,25 +0,0 @@ --- Roblox-specific context information -local function get_roblox_context(): table - local context = {} - - if _G.game then - local game = _G.game as any - context.game_id = game.GameId - context.place_id = game.PlaceId - context.job_id = game.JobId - end - - if _G.game and (_G.game as any).Players and (_G.game as any).Players.LocalPlayer then - local player = (_G.game as any).Players.LocalPlayer - context.player = { - name = player.Name, - user_id = player.UserId - } - end - - return context -end - -return { - get_roblox_context = get_roblox_context -} \ No newline at end of file diff --git a/src/sentry/platforms/roblox/file_io.tl b/src/sentry/platforms/roblox/file_io.tl deleted file mode 100644 index ab9d4c5..0000000 --- a/src/sentry/platforms/roblox/file_io.tl +++ /dev/null @@ -1,52 +0,0 @@ -local file_io = require("sentry.core.file_io") - -local record RobloxFileIO -end - -function RobloxFileIO:write_file(path: string, content: string): boolean, string - local success, err = pcall(function() - local DataStoreService = game:GetService("DataStoreService") - local datastore = DataStoreService:GetDataStore("SentryEvents") - - local timestamp = tostring(os.time()) - datastore:SetAsync(timestamp, content) - end) - - if success then - return true, "Event written to Roblox DataStore" - else - return false, "Roblox DataStore error: " .. tostring(err) - end -end - -function RobloxFileIO:read_file(path: string): string, string - local success, result = pcall(function() - local DataStoreService = game:GetService("DataStoreService") - local datastore = DataStoreService:GetDataStore("SentryEvents") - return datastore:GetAsync(path) - end) - - if success then - return result or "", "" - else - return "", "Failed to read from DataStore: " .. tostring(result) - end -end - -function RobloxFileIO:file_exists(path: string): boolean - local content, err = self:read_file(path) - return err == "" -end - -function RobloxFileIO:ensure_directory(path: string): boolean, string - return true, "Directories not needed for Roblox DataStore" -end - -local function create_roblox_file_io(): file_io.FileIO - return setmetatable({}, {__index = RobloxFileIO}) as file_io.FileIO -end - -return { - RobloxFileIO = RobloxFileIO, - create_roblox_file_io = create_roblox_file_io -} \ No newline at end of file diff --git a/src/sentry/platforms/roblox/os_detection.tl b/src/sentry/platforms/roblox/os_detection.tl deleted file mode 100644 index dcb118d..0000000 --- a/src/sentry/platforms/roblox/os_detection.tl +++ /dev/null @@ -1,23 +0,0 @@ -local os_utils = require("sentry.utils.os") -local OSInfo = os_utils.OSInfo - -local function detect_os(): OSInfo - -- In Roblox, we can't execute system commands - -- But we can detect we're in Roblox environment - if _G.game and _G.game.GetService then - return { - name = "Roblox", - version = nil - } - end - return nil -end - --- Register this detector -os_utils.register_detector({ - detect = detect_os -}) - -return { - detect_os = detect_os -} \ No newline at end of file diff --git a/src/sentry/platforms/roblox/transport.tl b/src/sentry/platforms/roblox/transport.tl deleted file mode 100644 index 5bdf259..0000000 --- a/src/sentry/platforms/roblox/transport.tl +++ /dev/null @@ -1,85 +0,0 @@ --- Roblox-specific transport using HttpService -local transport_utils = require("sentry.utils.transport") -local dsn_utils = require("sentry.utils.dsn") -local json = require("sentry.utils.json") -local version = require("sentry.version") - -local record RobloxTransport - endpoint: string - timeout: number - headers: {string: string} - dsn: dsn_utils.DSN -end - -function RobloxTransport:send(event: table): boolean, string - -- Check if we're in Roblox environment - if not _G.game then - return false, "Not in Roblox environment" - end - - local success_service, HttpService = pcall(function() - return (_G.game as any):GetService("HttpService") - end) - - if not success_service or not HttpService then - return false, "HttpService not available in Roblox" - end - - local body = json.encode(event) - - local success, response = pcall(function() - return HttpService:PostAsync(self.endpoint, body, - (_G as any).Enum.HttpContentType.ApplicationJson, - false, -- compress - self.headers - ) - end) - - if success then - return true, "Event sent via Roblox HttpService" - else - return false, "Roblox HTTP error: " .. tostring(response) - end -end - -function RobloxTransport:configure(config: table): transport_utils.Transport - local dsn, err = dsn_utils.parse_dsn((config as any).dsn or "") - if err then - error("Invalid DSN: " .. err) - end - - self.dsn = dsn - self.endpoint = dsn_utils.build_ingest_url(dsn) - self.timeout = (config as any).timeout or 30 - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-roblox/" .. version, - ["X-Sentry-Auth"] = dsn_utils.build_auth_header(dsn) - } - return self as transport_utils.Transport -end - --- Create factory function -local function create_roblox_transport(config: table): transport_utils.Transport - local transport = RobloxTransport - return transport:configure(config) -end - --- Check if Roblox transport is available -local function is_roblox_available(): boolean - return _G.game and (_G.game as any).GetService ~= nil -end - --- Register this transport factory with high priority for Roblox -transport_utils.register_transport_factory({ - name = "roblox", - priority = 200, -- Very high priority in Roblox environment - create = create_roblox_transport, - is_available = is_roblox_available -}) - -return { - RobloxTransport = RobloxTransport, - create_roblox_transport = create_roblox_transport, - is_roblox_available = is_roblox_available -} \ No newline at end of file diff --git a/src/sentry/platforms/standard/file_transport.tl b/src/sentry/platforms/standard/file_transport.tl deleted file mode 100644 index 8c699e5..0000000 --- a/src/sentry/platforms/standard/file_transport.tl +++ /dev/null @@ -1,89 +0,0 @@ --- File-based transport for environments without HTTP access -local transport_utils = require("sentry.utils.transport") -local file_io = require("sentry.core.file_io") -local json = require("sentry.utils.json") -local version = require("sentry.version") - -local record FileTransport - endpoint: string - timeout: number - headers: {string: string} - file_path: string - file_io: file_io.FileIO - append_mode: boolean -end - -function FileTransport:send(event: table): boolean, string - local serialized = json.encode(event) - local timestamp = os.date("%Y-%m-%d %H:%M:%S") - local content = string.format("[%s] %s\n", timestamp, serialized) - - if self.append_mode and self.file_io:file_exists(self.file_path) then - local existing_content, read_err = self.file_io:read_file(self.file_path) - if read_err ~= "" then - return false, "Failed to read existing file: " .. read_err - end - content = existing_content .. content - end - - local success, err = self.file_io:write_file(self.file_path, content) - - if success then - return true, "Event written to file: " .. self.file_path - else - return false, "Failed to write event: " .. err - end -end - -function FileTransport:configure(config: table): transport_utils.Transport - self.endpoint = (config as any).dsn or "" - self.timeout = (config as any).timeout or 30 - self.file_path = (config as any).file_path or "sentry-events.log" - self.append_mode = (config as any).append_mode ~= false - - if (config as any).file_io then - self.file_io = (config as any).file_io - else - self.file_io = file_io.create_standard_file_io() - end - - local dir_path = self.file_path:match("^(.*/)") - if dir_path then - local dir_success, dir_err = self.file_io:ensure_directory(dir_path) - if not dir_success then - print("Warning: Failed to create directory: " .. dir_err) - end - end - - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-file/" .. version - } - - return self as transport_utils.Transport -end - --- Create factory function -local function create_file_transport(config: table): transport_utils.Transport - local transport = FileTransport - return transport:configure(config) -end - --- File transport is always available as a fallback -local function is_file_available(): boolean - return true -end - --- Register this transport factory with lower priority (fallback) -transport_utils.register_transport_factory({ - name = "file", - priority = 10, -- Low priority - used as fallback - create = create_file_transport, - is_available = is_file_available -}) - -return { - FileTransport = FileTransport, - create_file_transport = create_file_transport, - is_file_available = is_file_available -} \ No newline at end of file diff --git a/src/sentry/platforms/standard/os_detection.tl b/src/sentry/platforms/standard/os_detection.tl deleted file mode 100644 index 9c0551f..0000000 --- a/src/sentry/platforms/standard/os_detection.tl +++ /dev/null @@ -1,81 +0,0 @@ -local os_utils = require("sentry.utils.os") -local OSInfo = os_utils.OSInfo - -local function detect_os(): OSInfo - -- Try to detect OS through standard Lua mechanisms - local handle = io.popen("uname -s 2>/dev/null") - if handle then - local name = handle:read("*a") - handle:close() - if name then - name = name:gsub("\n", "") - if name ~= "" then - -- Special handling for Darwin -> macOS - if name == "Darwin" then - -- Check if this is actually macOS by looking for system version - local sw_vers = io.popen("sw_vers -productVersion 2>/dev/null") - if sw_vers then - local macos_version = sw_vers:read("*a") - sw_vers:close() - if macos_version and macos_version:gsub("\n", "") ~= "" then - return { - name = "macOS", - version = macos_version:gsub("\n", "") - } - end - end - -- Fallback to Darwin if sw_vers not available - name = "Darwin" - end - - -- Get version for non-Darwin systems - local version_handle = io.popen("uname -r 2>/dev/null") - if version_handle then - local version = version_handle:read("*a") - version_handle:close() - if version then - version = version:gsub("\n", "") - return { - name = name, - version = version - } - end - end - - return { - name = name, - version = nil - } - end - end - end - - -- Fallback: try to detect through package.config - local sep = package.config:sub(1,1) - if sep == "\\" then - -- Windows - local handle_win = io.popen("ver 2>nul") - if handle_win then - local output = handle_win:read("*a") - handle_win:close() - if output and output:match("Microsoft Windows") then - local version = output:match("%[Version ([^%]]+)%]") - return { - name = "Windows", - version = version or nil - } - end - end - end - - return nil -end - --- Register this detector -os_utils.register_detector({ - detect = detect_os -}) - -return { - detect_os = detect_os -} \ No newline at end of file diff --git a/src/sentry/platforms/standard/transport.tl b/src/sentry/platforms/standard/transport.tl deleted file mode 100644 index 09dbd48..0000000 --- a/src/sentry/platforms/standard/transport.tl +++ /dev/null @@ -1,104 +0,0 @@ --- Standard HTTP transport for desktop platforms (Standard Lua, LuaJIT) -local transport_utils = require("sentry.utils.transport") -local dsn_utils = require("sentry.utils.dsn") -local json = require("sentry.utils.json") -local http = require("sentry.utils.http") -local version = require("sentry.version") - -local record HttpTransport - endpoint: string - envelope_endpoint: string - timeout: number - headers: {string: string} - envelope_headers: {string: string} - dsn: dsn_utils.DSN -end - -function HttpTransport:send(event: table): boolean, string - local body = json.encode(event) - - local request = { - url = self.endpoint, - method = "POST", - headers = self.headers, - body = body, - timeout = self.timeout - } - - local response = http.request(request) - - if response.success and response.status == 200 then - return true, "Event sent successfully" - else - local error_msg = response.error or "Failed to send event: " .. tostring(response.status) - return false, error_msg - end -end - --- Send transaction as envelope -function HttpTransport:send_envelope(envelope_body: string): boolean, string - local request = { - url = self.envelope_endpoint, - method = "POST", - headers = self.envelope_headers, - body = envelope_body, - timeout = self.timeout - } - - local response = http.request(request) - - if response.success and response.status == 200 then - return true, "Envelope sent successfully" - else - local error_msg = response.error or "Failed to send envelope: " .. tostring(response.status) - return false, error_msg - end -end - -function HttpTransport:configure(config: table): transport_utils.Transport - local dsn, err = dsn_utils.parse_dsn((config as any).dsn or "") - if err then - error("Invalid DSN: " .. err) - end - - self.dsn = dsn - self.endpoint = dsn_utils.build_ingest_url(dsn) - self.envelope_endpoint = dsn_utils.build_envelope_url(dsn) - self.timeout = (config as any).timeout or 30 - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua/" .. version, - ["X-Sentry-Auth"] = dsn_utils.build_auth_header(dsn) - } - self.envelope_headers = { - ["Content-Type"] = "application/x-sentry-envelope", - ["User-Agent"] = "sentry-lua/" .. version, - ["X-Sentry-Auth"] = dsn_utils.build_auth_header(dsn) - } - return self as transport_utils.Transport -end - --- Create factory function -local function create_http_transport(config: table): transport_utils.Transport - local transport = HttpTransport - return transport:configure(config) -end - --- Check if HTTP transport is available -local function is_http_available(): boolean - return http.available -end - --- Register this transport factory -transport_utils.register_transport_factory({ - name = "standard-http", - priority = 100, -- High priority for standard environments - create = create_http_transport, - is_available = is_http_available -}) - -return { - HttpTransport = HttpTransport, - create_http_transport = create_http_transport, - is_http_available = is_http_available -} \ No newline at end of file diff --git a/src/sentry/platforms/test/transport.tl b/src/sentry/platforms/test/transport.tl deleted file mode 100644 index 242ca87..0000000 --- a/src/sentry/platforms/test/transport.tl +++ /dev/null @@ -1,59 +0,0 @@ --- Test transport for capturing events in memory -local transport_utils = require("sentry.utils.transport") -local version = require("sentry.version") - -local record TestTransport - endpoint: string - timeout: number - headers: {string: string} - events: {table} -end - -function TestTransport:send(event: table): boolean, string - table.insert(self.events, event) - return true, "Event captured in test transport" -end - -function TestTransport:configure(config: table): transport_utils.Transport - self.endpoint = (config as any).dsn or "" - self.timeout = (config as any).timeout or 30 - self.headers = { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua-test/" .. version - } - self.events = {} - return self as transport_utils.Transport -end - -function TestTransport:get_events(): {table} - return self.events -end - -function TestTransport:clear_events() - self.events = {} -end - --- Create factory function -local function create_test_transport(config: table): transport_utils.Transport - local transport = TestTransport - return transport:configure(config) -end - --- Test transport is always available (good for testing) -local function is_test_available(): boolean - return true -end - --- Register this transport factory with very low priority (testing only) -transport_utils.register_transport_factory({ - name = "test", - priority = 1, -- Very low priority - only used when explicitly requested - create = create_test_transport, - is_available = is_test_available -}) - -return { - TestTransport = TestTransport, - create_test_transport = create_test_transport, - is_test_available = is_test_available -} \ No newline at end of file diff --git a/src/sentry/tracing/headers.tl b/src/sentry/tracing/headers.tl deleted file mode 100644 index ff6fc14..0000000 --- a/src/sentry/tracing/headers.tl +++ /dev/null @@ -1,342 +0,0 @@ ----@class sentry.tracing.headers ---- Core functionality for parsing and generating Sentry distributed tracing headers ---- Implements sentry-trace header format: {trace_id}-{span_id}-{sampled} ---- Supports both incoming trace continuation and outgoing trace propagation - -local record TraceData - trace_id: string - span_id: string - sampled: boolean -end - - -local record TraceInfo - sentry_trace: TraceData - baggage: {string: string} - traceparent: string -end - -local record InjectOptions - include_traceparent: boolean -end - -local record Headers - parse_sentry_trace: function(header_value: string): TraceData - generate_sentry_trace: function(trace_data: TraceData): string - parse_baggage: function(header_value: string): {string: string} - generate_baggage: function(baggage_data: {string: string}): string - generate_trace_id: function(): string - generate_span_id: function(): string - extract_trace_headers: function(http_headers: {string: string}): TraceInfo - inject_trace_headers: function(http_headers: {string: string}, trace_data: TraceData, baggage_data: {string: string}, options: InjectOptions) -end - -local headers: Headers = {} - -local utils = require("sentry.utils") - --- Constants for header parsing and validation -local TRACE_ID_LENGTH: number = 32 -- 128-bit trace ID as hex string -local SPAN_ID_LENGTH: number = 16 -- 64-bit span ID as hex string - ----Parse sentry-trace header value ----@param header_value string? The sentry-trace header value ----@return table|nil trace_data Parsed trace data with trace_id, span_id, and sampled fields -function headers.parse_sentry_trace(header_value: string): TraceData - if not header_value or type(header_value) ~= "string" then - return nil - end - - -- Remove whitespace - local trimmed = header_value:match("^%s*(.-)%s*$") - if not trimmed then - return nil - end - header_value = trimmed - - if #header_value == 0 then - return nil - end - - -- Split by dashes: {trace_id}-{span_id}-{sampled} - local parts: {string} = {} - for part in header_value:gmatch("[^%-]+") do - table.insert(parts, part) - end - - -- Must have at least trace_id and span_id - if #parts < 2 then - return nil - end - - local trace_id = parts[1] - local span_id = parts[2] - local sampled = parts[3] -- Optional - - -- Validate trace_id (32 hex characters) - if not trace_id or #trace_id ~= TRACE_ID_LENGTH then - return nil - end - - if not trace_id:match("^[0-9a-fA-F]+$") then - return nil - end - - -- Validate span_id (16 hex characters) - if not span_id or #span_id ~= SPAN_ID_LENGTH then - return nil - end - - if not span_id:match("^[0-9a-fA-F]+$") then - return nil - end - - -- Parse sampled flag (optional) - local parsed_sampled: boolean = nil - if sampled then - if sampled == "1" then - parsed_sampled = true - elseif sampled == "0" then - parsed_sampled = false - else - -- Invalid sampled value, ignore it (defer sampling decision) - parsed_sampled = nil - end - end - - return { - trace_id = trace_id:lower(), -- Normalize to lowercase - span_id = span_id:lower(), - sampled = parsed_sampled - } -end - ----Generate sentry-trace header value from trace data ----@param trace_data table Trace data with trace_id, span_id, and optional sampled fields ----@return string|nil header_value The sentry-trace header value or nil if invalid input -function headers.generate_sentry_trace(trace_data: TraceData): string - if not trace_data or type(trace_data) ~= "table" then - return nil - end - - local trace_id = trace_data.trace_id - local span_id = trace_data.span_id - local sampled = trace_data.sampled - - -- Validate required fields - if not trace_id or not span_id then - return nil - end - - -- Validate trace_id format - if type(trace_id) ~= "string" or #trace_id ~= TRACE_ID_LENGTH then - return nil - end - - if not trace_id:match("^[0-9a-fA-F]+$") then - return nil - end - - -- Validate span_id format - if type(span_id) ~= "string" or #span_id ~= SPAN_ID_LENGTH then - return nil - end - - if not span_id:match("^[0-9a-fA-F]+$") then - return nil - end - - -- Build header value - local header_value = trace_id:lower() .. "-" .. span_id:lower() - - -- Add sampled flag if specified - if sampled == true then - header_value = header_value .. "-1" - elseif sampled == false then - header_value = header_value .. "-0" - end - -- If sampled is nil, defer sampling decision (no sampled flag) - - return header_value -end - ----Parse baggage header value ----Baggage format: key1=value1,key2=value2,key3=value3;property=value ----@param header_value string? The baggage header value ----@return table baggage_data Parsed baggage data as key-value pairs -function headers.parse_baggage(header_value: string): {string: string} - local baggage_data: {string: string} = {} - - if not header_value or type(header_value) ~= "string" then - return baggage_data - end - - -- Remove whitespace - local trimmed = header_value:match("^%s*(.-)%s*$") - if not trimmed then - return baggage_data - end - header_value = trimmed - - if #header_value == 0 then - return baggage_data - end - - -- Split by comma to get individual baggage items - for item in header_value:gmatch("[^,]+") do - local trimmed_item = item:match("^%s*(.-)%s*$") -- Trim whitespace - if trimmed_item then - item = trimmed_item - end - - -- Split by semicolon to separate key=value from properties - local key_value_part = item:match("([^;]*)") - if key_value_part then - local trimmed_kvp = key_value_part:match("^%s*(.-)%s*$") - if trimmed_kvp then - key_value_part = trimmed_kvp - end - - -- Split key=value - local key, value = key_value_part:match("^([^=]+)=(.*)$") - if key and value then - local trimmed_key = key:match("^%s*(.-)%s*$") -- Trim key - local trimmed_value = value:match("^%s*(.-)%s*$") -- Trim value - - -- URL decode if needed (basic implementation) - if trimmed_key and trimmed_value and #trimmed_key > 0 then - baggage_data[trimmed_key] = trimmed_value - end - end - end - end - - return baggage_data -end - ----Generate baggage header value from baggage data ----@param baggage_data table Baggage data as key-value pairs ----@return string|nil header_value The baggage header value or nil if empty -function headers.generate_baggage(baggage_data: {string: string}): string - if not baggage_data or type(baggage_data) ~= "table" then - return nil - end - - local items: {string} = {} - for key, value in pairs(baggage_data) do - if type(key) == "string" and type(value) == "string" then - -- Basic URL encoding for special characters - local encoded_value = value:gsub("([,;=%%])", function(c: string): string - return string.format("%%%02X", string.byte(c)) - end) - - table.insert(items, key .. "=" .. encoded_value) - end - end - - if #items == 0 then - return nil - end - - return table.concat(items, ",") -end - ----Generate random trace ID (128-bit as 32 hex characters) ----@return string trace_id Random trace ID -function headers.generate_trace_id(): string - local uuid_result = utils.generate_uuid():gsub("-", "") - return uuid_result -end - ----Generate random span ID (64-bit as 16 hex characters) ----@return string span_id Random span ID -function headers.generate_span_id(): string - local uuid_result = utils.generate_uuid():gsub("-", ""):sub(1, 16) - return uuid_result -end - ----Extract trace propagation headers from HTTP headers table ----@param http_headers table HTTP headers as key-value pairs (case-insensitive lookup) ----@return table trace_info Extracted trace information -function headers.extract_trace_headers(http_headers: {string: string}): TraceInfo - if not http_headers or type(http_headers) ~= "table" then - return {} - end - - -- Case-insensitive header lookup - local function get_header(name: string): string - local name_lower = name:lower() - for key, value in pairs(http_headers) do - if type(key) == "string" and key:lower() == name_lower then - return value - end - end - return nil - end - - local trace_info: TraceInfo = {} - - -- Extract sentry-trace header - local sentry_trace = get_header("sentry-trace") - if sentry_trace then - trace_info.sentry_trace = headers.parse_sentry_trace(sentry_trace) - end - - -- Extract baggage header - local baggage = get_header("baggage") - if baggage then - trace_info.baggage = headers.parse_baggage(baggage) - end - - -- Extract traceparent header (W3C Trace Context) - local traceparent = get_header("traceparent") - if traceparent then - trace_info.traceparent = traceparent - end - - return trace_info -end - ----Inject trace propagation headers into HTTP headers table ----@param http_headers table HTTP headers table to modify ----@param trace_data table Trace data with trace_id, span_id, and optional sampled ----@param baggage_data table? Optional baggage data ----@param options table? Options for header injection -function headers.inject_trace_headers(http_headers: {string: string}, trace_data: TraceData, baggage_data: {string: string}, options: InjectOptions) - if not http_headers or type(http_headers) ~= "table" then - return - end - - if not trace_data or type(trace_data) ~= "table" then - return - end - - options = options or {} - - -- Inject sentry-trace header - local sentry_trace = headers.generate_sentry_trace(trace_data) - if sentry_trace then - http_headers["sentry-trace"] = sentry_trace - end - - -- Inject baggage header if provided - if baggage_data then - local baggage = headers.generate_baggage(baggage_data) - if baggage then - http_headers["baggage"] = baggage - end - end - - -- Inject traceparent header if requested (for OpenTelemetry interop) - if options.include_traceparent and trace_data.trace_id and trace_data.span_id then - -- W3C traceparent format: 00-{trace_id}-{span_id}-{flags} - local flags = "00" -- Default flags - if trace_data.sampled == true then - flags = "01" - end - - http_headers["traceparent"] = "00-" .. trace_data.trace_id .. "-" .. trace_data.span_id .. "-" .. flags - end -end - -return headers \ No newline at end of file diff --git a/src/sentry/tracing/init.tl b/src/sentry/tracing/init.tl deleted file mode 100644 index 4e45d55..0000000 --- a/src/sentry/tracing/init.tl +++ /dev/null @@ -1,322 +0,0 @@ ----@class sentry.tracing ---- Main tracing module for Sentry Lua SDK ---- Provides distributed tracing functionality with "Tracing without Performance" (TwP) mode by default ---- Handles trace continuation, propagation, and integration with HTTP clients/servers - -local record TracingConfig - trace_propagation_targets: {string} - include_traceparent: boolean - baggage_keys: {string} -end - -local record TraceContext - trace_id: string - span_id: string - parent_span_id: string -end - -local record TraceInfo - trace_id: string - span_id: string - parent_span_id: string - sampled: boolean - is_tracing_enabled: boolean -end - -local record IdPair - trace_id: string - span_id: string -end - -local record Tracing - headers: any - propagation: any - performance: any - _config: TracingConfig - - init: function(config: TracingConfig) - continue_trace_from_request: function(request_headers: {string: string}): TraceContext - get_request_headers: function(target_url: string): {string: string} - start_trace: function(options: {string: any}): TraceContext - start_transaction: function(name: string, op: string, options: {string: any}): any - finish_transaction: function(status: string) - start_span: function(op: string, description: string, options: {string: any}): any - finish_span: function(status: string) - create_child: function(options: {string: any}): TraceContext - get_current_trace_info: function(): TraceInfo - is_active: function(): boolean - clear: function() - attach_trace_context_to_event: function(event: {string: any}): {string: any} - get_envelope_trace_header: function(): {string: string} - wrap_http_request: function(http_client: function(string, {string: any}): any, url: string, options: {string: any}): any - wrap_http_handler: function(handler: function(any, any): any): function(any, any): any - generate_ids: function(): IdPair -end - -local tracing: Tracing = {} - -local headers = require("sentry.tracing.headers") -local propagation = require("sentry.tracing.propagation") - --- Load performance monitoring if available -local performance: any = nil -local has_performance, perf_module = pcall(require, "sentry.performance") -if has_performance then - performance = perf_module -end - --- Re-export core functionality -tracing.headers = headers -tracing.propagation = propagation -if performance then - tracing.performance = performance -end - ----Initialize tracing for the SDK ----@param config TracingConfig? Optional tracing configuration -function tracing.init(config: TracingConfig) - config = config or {} - - -- Store config for later use - tracing._config = config - - -- Initialize propagation context if not already present - local current = propagation.get_current_context() - if not current then - propagation.start_new_trace() - end -end - ----Continue trace from incoming HTTP request headers ----This should be called at the beginning of request handling ----@param request_headers table HTTP headers from incoming request ----@return table trace_context The trace context created/continued -function tracing.continue_trace_from_request(request_headers: {string: string}): TraceContext - local context = propagation.continue_trace_from_headers(request_headers) - - -- Attach trace context to current scope for events - local trace_context = propagation.get_trace_context_for_event() - if trace_context then - -- In a real implementation, this would integrate with the scope system - -- sentry.get_current_scope():set_context("trace", trace_context) - end - - return trace_context as TraceContext -end - ----Get headers to add to outgoing HTTP requests ----This should be called before making outgoing HTTP requests ----@param target_url string? The URL being requested (for trace propagation targeting) ----@return table headers HTTP headers to add to the request -function tracing.get_request_headers(target_url: string): {string: string} - local config = tracing._config or {} - - local options = { - trace_propagation_targets = config.trace_propagation_targets, - include_traceparent = config.include_traceparent - } - - return propagation.get_trace_headers_for_request(target_url, options) -end - ----Start a new trace manually ----@param options table? Options for the new trace ----@return table trace_context The new trace context -function tracing.start_trace(options: {string: any}): TraceContext - local context = propagation.start_new_trace(options) - return propagation.get_trace_context_for_event() as TraceContext -end - ----Start a new transaction with performance timing ----@param name string Transaction name ----@param op string Operation type (e.g., "http.server", "navigation") ----@param options table? Additional options ----@return table transaction The started transaction -function tracing.start_transaction(name: string, op: string, options: {string: any}): any - options = options or {} - - -- If performance monitoring is available, use it - if performance then - -- Get current trace context for transaction - local trace_context = propagation.get_current_context() - if trace_context then - options.trace_id = trace_context.trace_id - options.parent_span_id = trace_context.span_id - end - - return performance.start_transaction(name, op, options) - end - - -- Fallback: just start a trace without performance timing - return tracing.start_trace(options) -end - ----Finish the current transaction ----@param status string? Final status (default: "ok") -function tracing.finish_transaction(status: string) - if performance then - performance.finish_transaction(status) - end -end - ----Start a span within the current transaction ----@param op string Operation type (e.g., "db.query", "http.client") ----@param description string Span description ----@param options table? Additional options ----@return table span The started span -function tracing.start_span(op: string, description: string, options: {string: any}): any - if performance then - return performance.start_span(op, description, options) - end - - -- Fallback: create child context without timing - return tracing.create_child(options) -end - ----Finish the most recent span ----@param status string? Final status (default: "ok") -function tracing.finish_span(status: string) - if performance then - performance.finish_span(status) - end -end - ----Create a child span/operation (returns new context but doesn't change current) ----@param options table? Options for the child operation ----@return table child_context The child trace context -function tracing.create_child(options: {string: any}): TraceContext - local child_context = propagation.create_child_context(options) - return { - trace_id = child_context.trace_id, - span_id = child_context.span_id, - parent_span_id = child_context.parent_span_id - } -end - ----Get current trace information for debugging/logging ----@return table? trace_info Current trace information or nil if no active trace -function tracing.get_current_trace_info(): TraceInfo - local context = propagation.get_current_context() - if not context then - return nil - end - - return { - trace_id = context.trace_id, - span_id = context.span_id, - parent_span_id = context.parent_span_id, - sampled = context.sampled, - is_tracing_enabled = propagation.is_tracing_enabled() - } -end - ----Check if tracing is currently active ----@return boolean active True if tracing context is active -function tracing.is_active(): boolean - return propagation.is_tracing_enabled() -end - ----Clear current trace context -function tracing.clear() - propagation.clear_context() - tracing._config = nil -end - ----Attach trace context to an event (for error reporting, etc.) ----@param event table The event to modify ----@return table event The modified event with trace context -function tracing.attach_trace_context_to_event(event: {string: any}): {string: any} - if not event or type(event) ~= "table" then - return event - end - - local trace_context = propagation.get_trace_context_for_event() - if trace_context then - event.contexts = event.contexts or {} - event.contexts.trace = trace_context - end - - return event -end - ----Get dynamic sampling context for envelope headers ----@return table? dsc Dynamic sampling context for envelope header or nil -function tracing.get_envelope_trace_header(): {string: string} - return propagation.get_dynamic_sampling_context() -end - ----High-level wrapper for HTTP client requests with automatic header injection ----@param http_client function HTTP client function that accepts (url, options) ----@param url string The URL to request ----@param options table? Request options (headers will be merged) ----@return any result The result from the HTTP client function -function tracing.wrap_http_request(http_client: function(string, {string: any}): any, url: string, options: {string: any}): any - if type(http_client) ~= "function" then - error("http_client must be a function") - end - - options = options or {} - options.headers = options.headers or {} - - -- Add trace headers to the request - local trace_headers = tracing.get_request_headers(url) - for key, value in pairs(trace_headers) do - options.headers[key] = value - end - - -- Call the original HTTP client function - return http_client(url, options) -end - ----High-level wrapper for HTTP server request handling with automatic trace continuation ----@param handler function Request handler function that accepts (request, response) ----@return function wrapped_handler Wrapped handler that continues traces -function tracing.wrap_http_handler(handler: function(any, any): any): function(any, any): any - if type(handler) ~= "function" then - error("handler must be a function") - end - - return function(request: any, response: any): any - -- Continue trace from request headers - local request_headers: {string: string} = {} - - -- Extract headers from request object (format varies by HTTP library) - if request and request.headers then - request_headers = request.headers as {string: string} - elseif request and request.get_header then - -- Some libraries use methods to access headers - local get_header_fn = request.get_header as function(any, string): string - request_headers["sentry-trace"] = get_header_fn(request, "sentry-trace") - request_headers["baggage"] = get_header_fn(request, "baggage") - request_headers["traceparent"] = get_header_fn(request, "traceparent") - end - - -- Continue the trace - tracing.continue_trace_from_request(request_headers) - - -- Store original context to restore later - local original_context = propagation.get_current_context() - - local success, result = pcall(handler, request, response) - - -- Restore original context - propagation.set_current_context(original_context) - - if not success then - error(result) - end - - return result - end -end - ----Utility function to generate new trace and span IDs ----@return table ids Table with trace_id and span_id -function tracing.generate_ids(): IdPair - return { - trace_id = headers.generate_trace_id(), - span_id = headers.generate_span_id() - } -end - -return tracing \ No newline at end of file diff --git a/src/sentry/tracing/propagation.tl b/src/sentry/tracing/propagation.tl deleted file mode 100644 index f8c888e..0000000 --- a/src/sentry/tracing/propagation.tl +++ /dev/null @@ -1,313 +0,0 @@ ----@class sentry.tracing.propagation ---- Core tracing propagation context implementation ---- Implements "Tracing without Performance" (TwP) mode by default ---- Handles trace continuation from incoming headers and propagation to outgoing requests - -local record PropagationContext - trace_id: string - span_id: string - parent_span_id: string - sampled: boolean - baggage: {string: string} - dynamic_sampling_context: {string: string} -end - - -local record TracePropagationOptions - trace_propagation_targets: {string} - include_traceparent: boolean -end - -local record TraceOptions - baggage: {string: string} -end - -local record Propagation - create_context: function(trace_data: TraceData, baggage_data: {string: string}): PropagationContext - populate_dynamic_sampling_context: function(context: PropagationContext) - get_current_context: function(): PropagationContext - set_current_context: function(context: PropagationContext) - continue_trace_from_headers: function(http_headers: {string: string}): PropagationContext - get_trace_headers_for_request: function(target_url: string, options: TracePropagationOptions): {string: string} - get_trace_context_for_event: function(): {string: any} - get_dynamic_sampling_context: function(): {string: string} - start_new_trace: function(options: TraceOptions): PropagationContext - clear_context: function() - create_child_context: function(options: TraceOptions): PropagationContext - is_tracing_enabled: function(): boolean - get_current_trace_id: function(): string - get_current_span_id: function(): string -end - -local propagation: Propagation = {} - -local record TraceData - trace_id: string - span_id: string - sampled: boolean -end - -local headers = require("sentry.tracing.headers") - --- Current propagation context (stored per scope) -local current_context: PropagationContext = nil - ----Initialize a new propagation context ----@param trace_data table? Optional incoming trace data from headers ----@param baggage_data table? Optional baggage data ----@return PropagationContext context New propagation context -function propagation.create_context(trace_data: TraceData, baggage_data: {string: string}): PropagationContext - local context: PropagationContext = { - trace_id = "", - span_id = "", - parent_span_id = nil, - sampled = nil, - baggage = {}, - dynamic_sampling_context = {} - } - - if trace_data then - -- Continue incoming trace - context.trace_id = trace_data.trace_id - context.parent_span_id = trace_data.span_id - context.span_id = headers.generate_span_id() -- Generate new span ID - context.sampled = trace_data.sampled - else - -- Start new trace (TwP mode - defer sampling decision) - context.trace_id = headers.generate_trace_id() - context.span_id = headers.generate_span_id() - context.parent_span_id = nil - context.sampled = nil -- Deferred sampling decision - end - - context.baggage = baggage_data or {} - context.dynamic_sampling_context = {} - - -- Populate dynamic sampling context lazily - propagation.populate_dynamic_sampling_context(context) - - return context -end - ----Populate dynamic sampling context (DSC) for the trace ----@param context PropagationContext The propagation context to populate -function propagation.populate_dynamic_sampling_context(context: PropagationContext) - if not context or not context.trace_id then - return - end - - local dsc = context.dynamic_sampling_context - - -- Add trace ID to DSC - dsc["sentry-trace_id"] = context.trace_id - - -- Add public key (would come from SDK configuration) - -- dsc["sentry-public_key"] = sdk_config.public_key - - -- Add environment if available - -- dsc["sentry-environment"] = sdk_config.environment - - -- Add release if available - -- dsc["sentry-release"] = sdk_config.release - - -- Note: We don't add sentry-sampled in TwP mode since sampling is deferred - -- This will be added when regular tracing (with spans) is enabled -end - ----Get the current propagation context ----@return PropagationContext? context Current propagation context or nil -function propagation.get_current_context(): PropagationContext - return current_context -end - ----Set the current propagation context ----@param context PropagationContext? The propagation context to set -function propagation.set_current_context(context: PropagationContext) - current_context = context -end - ----Continue trace from incoming HTTP headers ----@param http_headers table HTTP headers from incoming request ----@return PropagationContext context New propagation context continuing the trace -function propagation.continue_trace_from_headers(http_headers: {string: string}): PropagationContext - local trace_info = headers.extract_trace_headers(http_headers) - - local trace_data: TraceData = nil - local baggage_data = trace_info.baggage or {} - - -- Priority: sentry-trace > traceparent (W3C) - if trace_info.sentry_trace then - local sentry_trace_data = trace_info.sentry_trace - trace_data = { - trace_id = sentry_trace_data.trace_id, - span_id = sentry_trace_data.span_id, - sampled = sentry_trace_data.sampled - } - elseif trace_info.traceparent then - -- Parse W3C traceparent: 00-{trace_id}-{span_id}-{flags} - local version, trace_id, span_id, flags = trace_info.traceparent:match("^([0-9a-fA-F][0-9a-fA-F])%-([0-9a-fA-F]+)%-([0-9a-fA-F]+)%-([0-9a-fA-F][0-9a-fA-F])$") - if version == "00" and trace_id and span_id and #trace_id == 32 and #span_id == 16 then - trace_data = { - trace_id = trace_id, - span_id = span_id, - sampled = (tonumber(flags, 16) or 0) > 0 and true or nil - } - end - end - - local context = propagation.create_context(trace_data, baggage_data) - propagation.set_current_context(context) - - return context -end - ----Get trace headers for outgoing HTTP requests ----@param target_url string? Optional target URL for trace propagation targeting ----@param options table? Options for header generation ----@return table headers HTTP headers to add to outgoing request -function propagation.get_trace_headers_for_request(target_url: string, options: TracePropagationOptions): {string: string} - local context = propagation.get_current_context() - if not context then - return {} - end - - options = options or {} - local result_headers: {string: string} = {} - - -- Check trace propagation targets (simplified - would use real target matching) - local should_propagate = true - if options.trace_propagation_targets then - should_propagate = false - for _, target in ipairs(options.trace_propagation_targets) do - if target == "*" then - -- Wildcard - propagate to all targets - should_propagate = true - break - elseif target_url and target_url:find(target) then - -- Pattern match (supports Lua patterns like example%.com) - should_propagate = true - break - end - end - end - - if not should_propagate then - return {} - end - - -- Generate trace data for propagation - local trace_data: TraceData = { - trace_id = context.trace_id, - span_id = context.span_id, - sampled = context.sampled - } - - -- Inject headers (cast to headers TraceData type) - local headers_trace_data = { - trace_id = trace_data.trace_id, - span_id = trace_data.span_id, - sampled = trace_data.sampled - } - headers.inject_trace_headers(result_headers, headers_trace_data, context.baggage, { - include_traceparent = options.include_traceparent - }) - - return result_headers -end - ----Create trace context for attaching to events ----@return table? trace_context Trace context for event.contexts.trace or nil -function propagation.get_trace_context_for_event(): {string: any} - local context = propagation.get_current_context() - if not context or not context.trace_id then - return nil - end - - return { - trace_id = context.trace_id, - span_id = context.span_id, - parent_span_id = context.parent_span_id, - -- Note: In TwP mode, we don't have actual span data like op, description, etc. - } -end - ----Get dynamic sampling context for envelope headers ----@return table? dsc Dynamic sampling context or nil -function propagation.get_dynamic_sampling_context(): {string: string} - local context = propagation.get_current_context() - if not context or not context.dynamic_sampling_context then - return nil - end - - -- Return copy to avoid mutations - local dsc: {string: string} = {} - for k, v in pairs(context.dynamic_sampling_context) do - dsc[k] = v - end - - return dsc -end - ----Start a new trace (reset propagation context) ----@param options table? Options for the new trace ----@return PropagationContext context New propagation context -function propagation.start_new_trace(options: TraceOptions): PropagationContext - options = options or {} - - local context = propagation.create_context(nil, options.baggage) - propagation.set_current_context(context) - - return context -end - ----Clear current propagation context -function propagation.clear_context() - current_context = nil -end - ----Create a child context (for creating child spans/operations) ----@param options table? Options for the child context ----@return PropagationContext context New child propagation context -function propagation.create_child_context(options: TraceOptions): PropagationContext - local parent_context = propagation.get_current_context() - if not parent_context then - -- No parent context, start new trace - return propagation.start_new_trace(options) - end - - options = options or {} - - local child_context: PropagationContext = { - trace_id = parent_context.trace_id, - span_id = headers.generate_span_id(), -- New span ID for child - parent_span_id = parent_context.span_id, - sampled = parent_context.sampled, - baggage = parent_context.baggage, - dynamic_sampling_context = parent_context.dynamic_sampling_context - } - - return child_context -end - ----Check if tracing is enabled (has active trace context) ----@return boolean enabled True if tracing context is active -function propagation.is_tracing_enabled(): boolean - local context = propagation.get_current_context() - return context ~= nil and context.trace_id ~= nil -end - ----Get trace ID from current context ----@return string? trace_id Current trace ID or nil -function propagation.get_current_trace_id(): string - local context = propagation.get_current_context() - return context and context.trace_id or nil -end - ----Get span ID from current context ----@return string? span_id Current span ID or nil -function propagation.get_current_span_id(): string - local context = propagation.get_current_context() - return context and context.span_id or nil -end - -return propagation \ No newline at end of file diff --git a/src/sentry/types.tl b/src/sentry/types.tl deleted file mode 100644 index feb3e1c..0000000 --- a/src/sentry/types.tl +++ /dev/null @@ -1,118 +0,0 @@ --- Core types for Sentry SDK - --- Sentry initialization options -local record SentryOptions - dsn: string - environment: string - release: string - debug: boolean - sample_rate: number - max_breadcrumbs: number - before_send: function(event: table): table - transport: any - test_transport: boolean - file_transport: boolean - file_path: string - append_mode: boolean -end - --- User information -local record User - id: string - email: string - username: string - ip_address: string - segment: string -end - --- Breadcrumb data -local record Breadcrumb - timestamp: number - message: string - category: string - level: string - data: {string: any} -end - --- Context information (runtime, OS, device, etc.) -local record RuntimeContext - name: string - version: string - description: string -end - -local record OSContext - name: string - version: string - build: string - kernel_version: string -end - -local record DeviceContext - name: string - family: string - model: string - model_id: string - arch: string - battery_level: number - orientation: string - manufacturer: string - brand: string -end - --- Stack frame information -local record StackFrame - filename: string - ["function"]: string -- Sentry expects "function", not "function_name" - lineno: number - in_app: boolean - vars: {string: any} -- local variables and parameters -end - -local record StackTrace - frames: {StackFrame} -end - --- Exception information -local record Exception - type: string - value: string - module: string - thread_id: string - stacktrace: StackTrace -end - --- Event data structure -local record EventData - event_id: string - timestamp: number - level: string - logger: string - platform: string - sdk: {string: any} - message: string - exception: table - stacktrace: StackTrace - user: User - tags: {string: string} - extra: {string: any} - breadcrumbs: {Breadcrumb} - environment: string - release: string - contexts: {string: any} -end - -local types = { - SentryOptions = SentryOptions, - User = User, - Breadcrumb = Breadcrumb, - RuntimeContext = RuntimeContext, - OSContext = OSContext, - DeviceContext = DeviceContext, - StackFrame = StackFrame, - StackTrace = StackTrace, - Exception = Exception, - EventData = EventData -} - -return types \ No newline at end of file diff --git a/src/sentry/utils.tl b/src/sentry/utils.tl deleted file mode 100644 index 21521c6..0000000 --- a/src/sentry/utils.tl +++ /dev/null @@ -1,177 +0,0 @@ ----@class sentry.utils ---- Utility functions for the Sentry Lua SDK ---- Provides helper functions for ID generation, encoding, etc. - -local record Utils - _random_seeded: boolean - - generate_uuid: function(): string - generate_hex: function(length: number): string - url_encode: function(str: string): string - url_decode: function(str: string): string - is_empty: function(str: string): boolean - trim: function(str: string): string - deep_copy: function(orig: T): T - merge_tables: function(t1: {K: V}, t2: {K: V}): {K: V} - get_timestamp: function(): number - get_timestamp_ms: function(): number -end - -local utils: Utils = {} - ----Generate a random UUID (version 4) ----@return string uuid A random UUID in the format xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx -function utils.generate_uuid(): string - -- Seed random number generator if not already seeded - if not utils._random_seeded then - math.randomseed(os.time()) - utils._random_seeded = true - end - - local template = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx" - - local result = template:gsub("[xy]", function(c: string): string - local v = (c == "x") and math.random(0, 15) or math.random(8, 11) - return string.format("%x", v) - end) - return result -end - ----Generate random hex string of specified length ----@param length number Number of hex characters to generate ----@return string hex_string Random hex string -function utils.generate_hex(length: number): string - if not utils._random_seeded then - math.randomseed(os.time()) - utils._random_seeded = true - end - - local hex_chars = "0123456789abcdef" - local result: {string} = {} - - for _ = 1, length do - local idx = math.random(1, #hex_chars) - table.insert(result, hex_chars:sub(idx, idx)) - end - - return table.concat(result) -end - ----URL encode a string ----@param str string String to encode ----@return string encoded_string URL encoded string -function utils.url_encode(str: string): string - if not str then - return "" - end - - str = tostring(str) - - -- Replace unsafe characters with percent encoding - str = str:gsub("([^%w%-%.%_%~])", function(c: string): string - return string.format("%%%02X", string.byte(c)) - end) - - return str -end - ----URL decode a string ----@param str string String to decode ----@return string decoded_string URL decoded string -function utils.url_decode(str: string): string - if not str then - return "" - end - - str = tostring(str) - - -- Replace percent encoding with actual characters - str = str:gsub("%%(%x%x)", function(hex: string): string - return string.char(tonumber(hex, 16) as integer) - end) - - return str -end - ----Check if a string is empty or nil ----@param str string? String to check ----@return boolean is_empty True if string is nil or empty -function utils.is_empty(str: string): boolean - return not str or str == "" -end - ----Trim whitespace from both ends of a string ----@param str string String to trim ----@return string trimmed_string String with whitespace removed from both ends -function utils.trim(str: string): string - if not str then - return "" - end - - return str:match("^%s*(.-)%s*$") or "" -end - ----Deep copy a table ----@param orig table Original table ----@return table copy Deep copy of the table -function utils.deep_copy(orig: T): T - local copy: any - if type(orig) == "table" then - copy = {} - local orig_table = orig as {any: any} - for orig_key, orig_value in next, orig_table, nil do - (copy as {any: any})[utils.deep_copy(orig_key)] = utils.deep_copy(orig_value) - end - setmetatable(copy as table, utils.deep_copy(getmetatable(orig as table))) - else - copy = orig - end - return copy as T -end - ----Merge two tables (shallow merge) ----@param t1 table First table ----@param t2 table Second table ----@return table merged_table Merged table -function utils.merge_tables(t1: {K: V}, t2: {K: V}): {K: V} - local result: {K: V} = {} - - if t1 then - for k, v in pairs(t1) do - result[k] = v - end - end - - if t2 then - for k, v in pairs(t2) do - result[k] = v - end - end - - return result -end - ----Get current timestamp in seconds ----@return number timestamp Current timestamp -function utils.get_timestamp(): number - return os.time() -end - ----Get current timestamp in milliseconds (best effort) ----@return number timestamp Current timestamp in milliseconds -function utils.get_timestamp_ms(): number - -- Try to use socket.gettime if available for higher precision - local success, socket_module = pcall(require, "socket") - if success and socket_module and type(socket_module) == "table" then - local socket_table = socket_module as {string: any} - if socket_table["gettime"] and type(socket_table["gettime"]) == "function" then - local gettime = socket_table["gettime"] as function(): number - return math.floor(gettime() * 1000) - end - end - - -- Fallback to seconds * 1000 - return os.time() * 1000 -end - -return utils \ No newline at end of file diff --git a/src/sentry/utils/dsn.tl b/src/sentry/utils/dsn.tl deleted file mode 100644 index e5e70b0..0000000 --- a/src/sentry/utils/dsn.tl +++ /dev/null @@ -1,110 +0,0 @@ -local version = require("sentry.version") - -local record DSN - protocol: string - public_key: string - secret_key: string - host: string - port: number - path: string - project_id: string -end - -local function parse_dsn(dsn_string: string): DSN, string - if not dsn_string or dsn_string == "" then - return {} as DSN, "DSN is required" - end - - -- Pattern to match DSN with optional secret key - -- https://public_key:secret_key@host:port/path/project_id - -- https://public_key@host:port/path/project_id - local protocol, credentials, host_path = dsn_string:match("^(https?)://([^@]+)@(.+)$") - - if not protocol or not credentials or not host_path then - return {} as DSN, "Invalid DSN format" - end - - -- Parse credentials (public_key or public_key:secret_key) - local public_key, secret_key = credentials:match("^([^:]+):(.+)$") - if not public_key then - public_key = credentials - secret_key = "" - end - - if not public_key or public_key == "" then - return {} as DSN, "Invalid DSN format" - end - - -- Parse host and path - local host, path = host_path:match("^([^/]+)(.*)$") - if not host or not path or path == "" then - return {} as DSN, "Invalid DSN format" - end - - -- Extract project ID from path (last numeric segment) - local project_id = path:match("/([%d]+)$") - if not project_id then - return {} as DSN, "Could not extract project ID from DSN" - end - - -- Parse port from host - local port = 443 - if protocol == "http" then - port = 80 - end - - local host_part, port_part = host:match("^([^:]+):?(%d*)$") - if host_part then - host = host_part - if port_part and port_part ~= "" then - port = tonumber(port_part) or port - end - end - - return { - protocol = protocol, - public_key = public_key, - secret_key = secret_key or "", - host = host, - port = port, - path = path, - project_id = project_id - }, nil -- Return nil instead of empty string for no error -end - -local function build_ingest_url(dsn: DSN): string - return string.format("%s://%s/api/%s/store/", - dsn.protocol, - dsn.host, - dsn.project_id) -end - -local function build_envelope_url(dsn: DSN): string - return string.format("%s://%s/api/%s/envelope/", - dsn.protocol, - dsn.host, - dsn.project_id) -end - -local function build_auth_header(dsn: DSN): string - local auth_parts = { - "Sentry sentry_version=7", - "sentry_key=" .. dsn.public_key, - "sentry_client=sentry-lua/" .. version - } - - if dsn.secret_key and dsn.secret_key ~= "" then - table.insert(auth_parts, "sentry_secret=" .. dsn.secret_key) - end - - return table.concat(auth_parts, ", ") -end - - -return { - parse_dsn = parse_dsn, - build_ingest_url = build_ingest_url, - build_envelope_url = build_envelope_url, - build_auth_header = build_auth_header, - DSN = DSN -} \ No newline at end of file diff --git a/src/sentry/utils/envelope.tl b/src/sentry/utils/envelope.tl deleted file mode 100644 index 97abd18..0000000 --- a/src/sentry/utils/envelope.tl +++ /dev/null @@ -1,84 +0,0 @@ ----@class sentry.utils.envelope ---- Envelope formatting utilities for Sentry transactions, performance data, and logs - -local json = require("sentry.utils.json") - ----Build an envelope for a transaction ----@param transaction table Transaction data ----@param event_id string Event ID ----@return string envelope_body The formatted envelope body -local function build_transaction_envelope(transaction: table, event_id: string): string - -- Get current timestamp in RFC 3339 format - local sent_at = os.date("!%Y-%m-%dT%H:%M:%SZ") - - -- Envelope header - local envelope_header = { - event_id = event_id, - sent_at = sent_at - } - - -- Transaction JSON payload - local transaction_json = json.encode(transaction) - local payload_length = string.len(transaction_json) - - -- Item header - local item_header = { - type = "transaction", - length = payload_length - } - - -- Build envelope: header + newline + item header + newline + payload - local envelope_parts: {string} = { - json.encode(envelope_header), - json.encode(item_header), - transaction_json - } - - return table.concat(envelope_parts, "\n") -end - ----Build an envelope for log records ----@param log_records table Array of log records ----@return string envelope_body The formatted envelope body -local function build_log_envelope(log_records: {any}): string - if not log_records or #log_records == 0 then - return "" - end - - -- Get current timestamp in RFC 3339 format - local sent_at: string = os.date("!%Y-%m-%dT%H:%M:%SZ") or "" - - -- Envelope header - local envelope_header: {string: any} = { - sent_at = sent_at - } - - -- Build log items array (all logs in a single envelope item) - local log_items: {string: any} = { - items = log_records - } - - -- Log JSON payload - local log_json: string = json.encode(log_items) - - -- Item header for log envelope - local item_header: {string: any} = { - type = "log", - item_count = #log_records, - content_type = "application/vnd.sentry.items.log+json" - } - - -- Build envelope: header + newline + item header + newline + payload - local envelope_parts: {string} = { - json.encode(envelope_header), - json.encode(item_header), - log_json - } - - return table.concat(envelope_parts, "\n") -end - -return { - build_transaction_envelope = build_transaction_envelope, - build_log_envelope = build_log_envelope -} \ No newline at end of file diff --git a/src/sentry/utils/http.tl b/src/sentry/utils/http.tl deleted file mode 100644 index 150e6fb..0000000 --- a/src/sentry/utils/http.tl +++ /dev/null @@ -1,178 +0,0 @@ --- Platform-agnostic HTTP handling -local record HTTPResponse - status: number - body: string - success: boolean - error: string -end - -local record HTTPRequest - url: string - method: string - headers: {string: string} - body: string - timeout: number -end - --- Platform-specific HTTP implementations -local http_impl = nil -local http_type = "none" - --- Try different HTTP libraries -local function try_luasocket() - local success, http = pcall(require, "socket.http") - local success_https, https = pcall(require, "ssl.https") - local success_ltn12, ltn12 = pcall(require, "ltn12") - if success and success_ltn12 then - return { - request = function(req: HTTPRequest): HTTPResponse - local url = req.url - local is_https = url:match("^https://") - local http_lib = (is_https and success_https) and https or http - - if not http_lib then - return { - success = false, - error = "HTTPS not supported", - status = 0, - body = "" - } - end - - local response_body = {} - local result, status = http_lib.request({ - url = url, - method = req.method, - headers = req.headers, - source = req.body and ltn12.source.string(req.body) or nil, - sink = ltn12.sink.table(response_body) - }) - - return { - success = result ~= nil, - status = status or 0, - body = table.concat(response_body or {}), - error = result and "" or "HTTP request failed" - } - end - }, "luasocket" - end - return nil, nil -end - -local function try_roblox() - if _G.game and _G.game.GetService then - local HttpService = game:GetService("HttpService") - if HttpService then - return { - request = function(req: HTTPRequest): HTTPResponse - local success, response = pcall(function() - return HttpService:RequestAsync({ - Url = req.url, - Method = req.method, - Headers = req.headers, - Body = req.body - }) - end) - - if success and response then - return { - success = true, - status = response.StatusCode, - body = response.Body, - error = "" - } - else - return { - success = false, - status = 0, - body = "", - error = tostring(response) - } - end - end - }, "roblox" - end - end - return nil, nil -end - -local function try_openresty() - if _G.ngx then - local success, httpc = pcall(require, "resty.http") - if success then - return { - request = function(req: HTTPRequest): HTTPResponse - local http_client = httpc:new() - http_client:set_timeout((req.timeout or 30) * 1000) - - local res, err = http_client:request_uri(req.url, { - method = req.method, - body = req.body, - headers = req.headers - }) - - if res then - return { - success = true, - status = res.status, - body = res.body, - error = "" - } - else - return { - success = false, - status = 0, - body = "", - error = err or "HTTP request failed" - } - end - end - }, "openresty" - end - end - return nil, nil -end - --- Try implementations in order -local implementations = { - try_roblox, -- Try Roblox first (most restrictive) - try_openresty, -- Try OpenResty - try_luasocket, -- Try standard Lua -} - -for _, impl_func in ipairs(implementations) do - local impl, impl_type = impl_func() - if impl then - http_impl = impl - http_type = impl_type - break - end -end - --- Fallback implementation (no-op) -if not http_impl then - http_impl = { - request = function(req: HTTPRequest): HTTPResponse - return { - success = false, - status = 0, - body = "", - error = "No HTTP implementation available" - } - end - } - http_type = "none" -end - -local function request(req: HTTPRequest): HTTPResponse - return http_impl.request(req) -end - -return { - request = request, - available = http_type ~= "none", - type = http_type, - HTTPRequest = HTTPRequest, - HTTPResponse = HTTPResponse -} \ No newline at end of file diff --git a/src/sentry/utils/json.tl b/src/sentry/utils/json.tl deleted file mode 100644 index e4a663f..0000000 --- a/src/sentry/utils/json.tl +++ /dev/null @@ -1,109 +0,0 @@ --- Platform-agnostic JSON handling -local json_lib = {} - --- Try to load JSON library with fallbacks -local json_impl = nil -local json_type = "none" - --- Try different JSON libraries in order of preference -local json_libraries = { - {name = "cjson", type = "cjson"}, - {name = "dkjson", type = "dkjson"}, - {name = "json", type = "json"}, -} - -for _, lib in ipairs(json_libraries) do - local success, json_module = pcall(require, lib.name) - if success then - json_impl = json_module - json_type = lib.type - break - end -end - --- Roblox has built-in JSON -if not json_impl and _G.game and _G.game.GetService then - local HttpService = game:GetService("HttpService") - if HttpService then - json_impl = { - encode = function(obj) return HttpService:JSONEncode(obj) end, - decode = function(str) return HttpService:JSONDecode(str) end - } - json_type = "roblox" - end -end - --- Simple fallback JSON encoder (basic, but works) -if not json_impl then - local function simple_encode(obj) - if type(obj) == "string" then - return '"' .. obj:gsub('\\', '\\\\'):gsub('"', '\\"') .. '"' - elseif type(obj) == "number" or type(obj) == "boolean" then - return tostring(obj) - elseif obj == nil then - return "null" - elseif type(obj) == "table" then - local result = {} - local is_array = true - local array_index = 1 - - -- Check if it's an array - for k, _ in pairs(obj) do - if k ~= array_index then - is_array = false - break - end - array_index = array_index + 1 - end - - if is_array then - -- Array - for i, v in ipairs(obj) do - table.insert(result, simple_encode(v)) - end - return "[" .. table.concat(result, ",") .. "]" - else - -- Object - for k, v in pairs(obj) do - if type(k) == "string" then - table.insert(result, '"' .. k .. '":' .. simple_encode(v)) - end - end - return "{" .. table.concat(result, ",") .. "}" - end - end - return "null" - end - - json_impl = { - encode = simple_encode, - decode = function(str) - error("JSON decoding not supported in fallback mode") - end - } - json_type = "fallback" -end - --- Unified interface -local function encode(obj) - if json_type == "dkjson" then - return json_impl.encode(obj) - else - return json_impl.encode(obj) - end -end - -local function decode(str) - if json_type == "dkjson" then - return json_impl.decode(str) - else - return json_impl.decode(str) - end -end - -return { - encode = encode, - decode = decode, - available = json_impl ~= nil, - type = json_type -} \ No newline at end of file diff --git a/src/sentry/utils/os.tl b/src/sentry/utils/os.tl deleted file mode 100644 index 9bfa0d8..0000000 --- a/src/sentry/utils/os.tl +++ /dev/null @@ -1,36 +0,0 @@ -local record OSInfo - name: string - version: string -end - --- Platform interface that each platform must implement -local record OSDetector - detect: function(): OSInfo -end - -local detectors: {OSDetector} = {} - -local function register_detector(detector: OSDetector) - table.insert(detectors, detector) -end - -local function get_os_info(): OSInfo - -- Try each registered detector until one succeeds - for _, detector in ipairs(detectors) do - local success, result = pcall(detector.detect) - if success and result then - return result - end - end - - -- If we can't detect anything, return nil - return nil -end - -return { - OSInfo = OSInfo, - OSDetector = OSDetector, - register_detector = register_detector, - get_os_info = get_os_info, - detectors = detectors -- Expose for testing -} \ No newline at end of file diff --git a/src/sentry/utils/runtime.tl b/src/sentry/utils/runtime.tl deleted file mode 100644 index 0053dcd..0000000 --- a/src/sentry/utils/runtime.tl +++ /dev/null @@ -1,122 +0,0 @@ --- Runtime detection utility for different Lua environments - -local record RuntimeInfo - name: string - version: string - description: string -end - -local function detect_standard_lua(): RuntimeInfo - -- Standard Lua runtime - local version = _VERSION or "Lua (unknown version)" - return { - name = "Lua", - version = version:match("Lua (%d+%.%d+)") or version, - description = version - } -end - -local function detect_luajit(): RuntimeInfo - -- LuaJIT detection - if jit and jit.version then - return { - name = "LuaJIT", - version = jit.version:match("LuaJIT (%S+)") or jit.version, - description = jit.version - } - end - return nil -end - -local function detect_roblox(): RuntimeInfo - -- Roblox Luau detection - if game and game.GetService then - -- Roblox environment detected - local version = "Unknown" - -- Try to get version info if available - if version and version ~= "" then - return { - name = "Luau", - version = version, - description = "Roblox Luau " .. version - } - else - return { - name = "Luau", - description = "Roblox Luau" - } - end - end - return nil -end - -local function detect_defold(): RuntimeInfo - -- Defold detection - if sys and sys.get_engine_info then - local engine_info = sys.get_engine_info() - return { - name = "defold", - version = engine_info.version or "unknown", - description = "Defold " .. (engine_info.version or "unknown") - } - end - return nil -end - -local function detect_love2d(): RuntimeInfo - -- Love2D detection - if love and love.getVersion then - local major, minor, revision, codename = love.getVersion() - local version = string.format("%d.%d.%d", major, minor, revision) - return { - name = "love2d", - version = version, - description = "LÖVE " .. version .. " (" .. (codename or "") .. ")" - } - end - return nil -end - -local function detect_openresty(): RuntimeInfo - -- OpenResty/ngx_lua detection - if ngx and ngx.var then - local version = "unknown" - if ngx.config and ngx.config.ngx_lua_version then - version = ngx.config.ngx_lua_version - end - return { - name = "OpenResty", - version = version, - description = "OpenResty/ngx_lua " .. version - } - end - return nil -end - -local function get_runtime_info(): RuntimeInfo - -- Try to detect specific environments first - local detectors = { - detect_roblox, - detect_defold, - detect_love2d, - detect_openresty, - detect_luajit - } - - for _, detector in ipairs(detectors) do - local result = detector() - if result then - return result - end - end - - -- Fall back to standard Lua - return detect_standard_lua() -end - -local runtime = { - get_runtime_info = get_runtime_info, - RuntimeInfo = RuntimeInfo -} - -return runtime \ No newline at end of file diff --git a/src/sentry/utils/serialize.tl b/src/sentry/utils/serialize.tl deleted file mode 100644 index 6767dc8..0000000 --- a/src/sentry/utils/serialize.tl +++ /dev/null @@ -1,76 +0,0 @@ -local json = require("sentry.utils.json") -local version = require("sentry.version") - -local record EventData - event_id: string - timestamp: number - level: string - logger: string - platform: string - sdk: table - message: string - exception: table - stacktrace: table - user: table - tags: {string: string} - extra: {string: any} - breadcrumbs: {table} - environment: string - release: string - contexts: {string: any} -end - -local function generate_event_id(): string - local chars = "abcdef0123456789" - local result: {string} = {} - - for _ = 1, 32 do - local rand_idx = math.random(1, #chars) - table.insert(result, chars:sub(rand_idx, rand_idx)) - end - - return table.concat(result) -end - --- Create a basic event structure - scope data will be applied separately -local function create_event(level: string, message: string, environment: string, release: string, stack_trace: table): EventData - local event: EventData = { - event_id = generate_event_id(), - timestamp = os.time(), - level = level, - platform = "lua", - sdk = { - name = "sentry.lua", - version = version - }, - message = message, - environment = environment or "production", - release = release, - user = {}, - tags = {}, - extra = {}, - breadcrumbs = {}, - contexts = {} - } - - if stack_trace and stack_trace.frames then - event.stacktrace = { - frames = stack_trace.frames - } - end - - return event -end - -local function serialize_event(event: EventData): string - return json.encode(event) -end - -local serialize = { - create_event = create_event, - serialize_event = serialize_event, - generate_event_id = generate_event_id, - EventData = EventData -} - -return serialize \ No newline at end of file diff --git a/src/sentry/utils/stacktrace.tl b/src/sentry/utils/stacktrace.tl deleted file mode 100644 index 0b65f8c..0000000 --- a/src/sentry/utils/stacktrace.tl +++ /dev/null @@ -1,204 +0,0 @@ -local record StackFrame - filename: string - ["function"]: string -- Sentry expects "function", not "function_name" - lineno: number - in_app: boolean - vars: {string:any} -- local variables and parameters - abs_path: string - context_line: string - pre_context: {string} - post_context: {string} -end - -local record StackTrace - frames: {StackFrame} -end - --- Read source context from a file -local function get_source_context(filename: string, line_number: number): string, {string}, {string} - local empty_array: {string} = {} - - if line_number <= 0 then - return "", empty_array, empty_array - end - - -- Try to open and read the file - local file = io.open(filename, "r") - if not file then - return "", empty_array, empty_array - end - - -- Read all lines into array (using dynamic indexing to avoid Teal issues) - local all_lines: {any} = {} - local line_count = 0 - for line in file:lines() do - line_count = line_count + 1 - all_lines[line_count] = line - end - file:close() - - -- Extract context lines - local context_line = "" - local pre_context: {string} = {} - local post_context: {string} = {} - - if line_number > 0 and line_number <= line_count then - context_line = (all_lines[line_number] as string) or "" - - -- Get 5 lines before - for i = math.max(1, line_number - 5), line_number - 1 do - if i >= 1 and i <= line_count then - table.insert(pre_context, (all_lines[i] as string) or "") - end - end - - -- Get 5 lines after - for i = line_number + 1, math.min(line_count, line_number + 5) do - if i >= 1 and i <= line_count then - table.insert(post_context, (all_lines[i] as string) or "") - end - end - end - - return context_line, pre_context, post_context -end - -local function get_stack_trace(skip_frames: number): StackTrace - skip_frames = skip_frames or 0 - local frames: {StackFrame} = {} - local level = 2 + (skip_frames or 0) - - while true do - local info = debug.getinfo(level as integer, "nSluf") - if not info then - break - end - - local filename = info.source or "unknown" - if filename:sub(1, 1) == "@" then - filename = filename:sub(2) - elseif filename == "=[C]" then - filename = "[C]" - end - - -- Determine if this frame is part of the user's application - local in_app = true - if not info.source then - in_app = false -- No source info - elseif filename == "[C]" then - in_app = false -- C function - elseif info.source:match("sentry") then - in_app = false -- SDK code - elseif filename:match("^/opt/homebrew") then - in_app = false -- Homebrew-installed libraries - end - - -- Get function name with better fallback - local function_name = info.name or "anonymous" - if info.namewhat and info.namewhat ~= "" then - function_name = info.name or "anonymous" - elseif info.what == "main" then - function_name = "

" - elseif info.what == "C" then - function_name = info.name or "" - end - - -- Collect local variables and parameters - local vars: {string:any} = {} - if info.what == "Lua" and in_app then -- Only collect vars for user code - -- Get parameters first (they're usually the first N locals) - for i = 1, (info.nparams or 0) do - local name, value = debug.getlocal(level as integer, i) - if name and not name:match("^%(") then -- Skip internal vars like (*temporary) - local safe_value = value - local value_type = type(value) - if value_type == "function" then - safe_value = "" - elseif value_type == "userdata" then - safe_value = "" - elseif value_type == "thread" then - safe_value = "" - elseif value_type == "table" then - safe_value = "" - end - vars[name] = safe_value - end - end - - -- Get other local variables (skip parameters we already got) - for i = (info.nparams or 0) + 1, 20 do -- reasonable limit - local name, value = debug.getlocal(level as integer, i) - if not name then break end - if not name:match("^%(") then -- Skip internal vars like (*temporary) - local safe_value = value - local value_type = type(value) - if value_type == "function" then - safe_value = "" - elseif value_type == "userdata" then - safe_value = "" - elseif value_type == "thread" then - safe_value = "" - elseif value_type == "table" then - safe_value = "
" - end - vars[name] = safe_value - end - end - end - - -- Ensure line number is non-negative for Sentry compatibility - local line_number = info.currentline or 0 - if line_number < 0 then - line_number = 0 - end - - -- Get source context if this is an in-app frame with valid line number - local context_line, pre_context, post_context = get_source_context(filename, line_number) - - local frame: StackFrame = { - filename = filename, - ["function"] = function_name, - lineno = line_number, - in_app = in_app, - vars = vars, - abs_path = filename, -- For now, same as filename - context_line = context_line, - pre_context = pre_context, - post_context = post_context - } - - table.insert(frames, frame) - level = level + 1 - end - - -- Invert frames for Sentry (outermost to innermost) - local inverted_frames: {StackFrame} = {} - for i = #frames, 1, -1 do - table.insert(inverted_frames, frames[i]) - end - - return {frames = inverted_frames} -end - -local function format_stack_trace(stack_trace: StackTrace): string - local lines: {string} = {} - - for _, frame in ipairs(stack_trace.frames) do - local line = string.format(" %s:%d in %s", - frame.filename, - frame.lineno as integer, - frame["function"]) - table.insert(lines, line) - end - - return table.concat(lines, "\n") -end - -local stacktrace = { - get_stack_trace = get_stack_trace, - format_stack_trace = format_stack_trace, - StackTrace = StackTrace, - StackFrame = StackFrame -} - -return stacktrace \ No newline at end of file diff --git a/src/sentry/utils/transport.tl b/src/sentry/utils/transport.tl deleted file mode 100644 index 96ce5b7..0000000 --- a/src/sentry/utils/transport.tl +++ /dev/null @@ -1,56 +0,0 @@ --- Platform-agnostic transport registry with dependency injection -local record Transport - send: function(self: Transport, event: table): boolean, string - configure: function(self: Transport, config: table): Transport -end - -local record TransportFactory - name: string - priority: number - create: function(config: table): Transport - is_available: function(): boolean -end - -local factories: {TransportFactory} = {} - --- Register a transport factory -local function register_transport_factory(factory: TransportFactory) - table.insert(factories, factory) - -- Sort by priority (higher priority first) - table.sort(factories, function(a: TransportFactory, b: TransportFactory): boolean - return a.priority > b.priority - end) -end - --- Get the best available transport for the current platform -local function create_transport(config: table): Transport - for _, factory in ipairs(factories) do - if factory.is_available() then - return factory.create(config) - end - end - - -- Fallback to test transport if nothing else works - local TestTransport = require("sentry.core.test_transport") - return TestTransport:configure(config) as Transport -end - --- Get available transport names for debugging -local function get_available_transports(): {string} - local available = {} - for _, factory in ipairs(factories) do - if factory.is_available() then - table.insert(available, factory.name) - end - end - return available -end - -return { - Transport = Transport, - TransportFactory = TransportFactory, - register_transport_factory = register_transport_factory, - create_transport = create_transport, - get_available_transports = get_available_transports, - factories = factories -} \ No newline at end of file diff --git a/src/sentry/version.tl b/src/sentry/version.tl deleted file mode 100644 index ee2128e..0000000 --- a/src/sentry/version.tl +++ /dev/null @@ -1,6 +0,0 @@ --- Sentry Lua SDK Version --- This file is automatically updated by the bump-version script - -local VERSION = "0.0.6" - -return VERSION \ No newline at end of file diff --git a/tealdoc.yml b/tealdoc.yml deleted file mode 100644 index c78367b..0000000 --- a/tealdoc.yml +++ /dev/null @@ -1,41 +0,0 @@ -project_name: "Sentry Lua SDK" -project_version: "0.0.6" -source_dir: "src" -output_dir: "docs" -title: "Sentry SDK for Lua Documentation" -description: "Platform-agnostic Sentry SDK for Lua environments" - -modules: - - name: "sentry" - path: "src/sentry/init.tl" - description: "Main Sentry SDK interface" - - - name: "sentry.core.client" - path: "src/sentry/core/client.tl" - description: "Core client implementation" - - - name: "sentry.core.transport" - path: "src/sentry/core/transport.tl" - description: "Event transport abstraction" - - - name: "sentry.core.context" - path: "src/sentry/core/context.tl" - description: "Context and breadcrumb management" - - - name: "sentry.utils.stacktrace" - path: "src/sentry/utils/stacktrace.tl" - description: "Stack trace utilities" - - - name: "sentry.utils.serialize" - path: "src/sentry/utils/serialize.tl" - description: "Event serialization utilities" - -examples: - - name: "Basic Usage" - file: "examples/basic.lua" - - - name: "Redis Integration" - file: "examples/redis.lua" - - - name: "nginx Integration" - file: "examples/nginx.lua" \ No newline at end of file diff --git a/tlconfig.lua b/tlconfig.lua deleted file mode 100644 index 521a232..0000000 --- a/tlconfig.lua +++ /dev/null @@ -1,14 +0,0 @@ -return { - source_dir = "src", - build_dir = "build", - module_name = "sentry", - include_dir = { - "src", - }, - external_libs = { - "busted", - }, - target_extension = "lua", - gen_compat = "optional", - gen_target = "5.1" -} \ No newline at end of file From 79e45f6206e9173c328c6892736071791e196eab Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sat, 23 Aug 2025 20:06:03 -0400 Subject: [PATCH 02/23] load modules --- .github/workflows/test.yml | 2 +- CHANGELOG.md | 2 +- README.md | 1 - examples/basic.lua | 11 +-- scripts/bump-version.ps1 | 4 - src/sentry/core/client.lua | 7 ++ src/sentry/environments/default.lua | 0 src/sentry/init.lua | 114 ++++++++++++++++++++++++++++ 8 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 src/sentry/core/client.lua create mode 100644 src/sentry/environments/default.lua create mode 100644 src/sentry/init.lua diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 048f9b5..e6d28ce 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -181,7 +181,7 @@ jobs: cp README.md dist-temp/ || { echo "❌ README.md not found"; exit 1; } # Copy directories (recursively) - cp -r build dist-temp/ || { echo "❌ build directory not found. Run 'make build' first."; exit 1; } + cp -r src dist-temp/ || { echo "❌ src directory not found; exit 1; } cp -r spec dist-temp/ || { echo "❌ spec directory not found"; exit 1; } cp -r examples dist-temp/ || { echo "❌ examples directory not found"; exit 1; } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e9636c..9407ec2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,7 +48,7 @@ Sentry Hackweek 2025 - **Experimental** SDK -* Portable Lua SDK, written in Teal, Lua 5.1 compatible. +* Portable Lua SDK, Lua 5.1 compatible. * CI and tests on Standard Lua and LuaJIT on macOS and Linux * Sentry Features include: * Error reporting with source context and local variable values diff --git a/README.md b/README.md index 2f54a60..2bd73d6 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ one of [Sentry's latest platform investments](https://blog.sentry.io/playstation ### LuaRocks (macOS/Linux) ```bash -# Install from LuaRocks.org - requires Unix-like system for Teal compilation luarocks install sentry/sdk ``` **Note:** Use `sentry/sdk` (not just `sentry`) as the plain `sentry` package is not a Sentry SDK. diff --git a/examples/basic.lua b/examples/basic.lua index ae2354a..b6e46b1 100644 --- a/examples/basic.lua +++ b/examples/basic.lua @@ -1,7 +1,5 @@ --- NOTE: Before running this example, compile the Teal files into Lua - -package.path = "build/?.lua;build/?/init.lua;" .. package.path -local sentry = require("sentry.init") +package.path = "src/?/init.lua;" .. package.path +local sentry = require("sentry") -- Initialize Sentry with your DSN sentry.init({ @@ -9,9 +7,6 @@ sentry.init({ environment = "production", release = "wrap-demo@1.0", debug = true, - -- file_transport = true, - -- file_path = "sentry-events.log", - -- append_mode = true }) -- Set user context @@ -264,4 +259,4 @@ sentry.with_scope(function(scope) end) -- Clean up -sentry.close() \ No newline at end of file +sentry:close() \ No newline at end of file diff --git a/scripts/bump-version.ps1 b/scripts/bump-version.ps1 index 0814a37..81849b9 100644 --- a/scripts/bump-version.ps1 +++ b/scripts/bump-version.ps1 @@ -30,9 +30,6 @@ function Replace-TextInFile { $repoRoot = "$PSScriptRoot/.." -# Update tealdoc.yml -Replace-TextInFile "$repoRoot/tealdoc.yml" '(?<=project_version: ").*?(?=")' $newVersion - # Update roblox.json Replace-TextInFile "$repoRoot/roblox.json" '(?<="version": ").*?(?=")' $newVersion @@ -69,4 +66,3 @@ Replace-TextInFile "$repoRoot/src/sentry/version.lua" '(?<=VERSION = ").*?(?=")' Replace-TextInFile "$repoRoot/spec/sentry_spec.lua" '(?<=sentry\.set_tag\("version", ").*?(?=")' $newVersion Write-Host "Version bump completed successfully to $newVersion" -Write-Host "Please run 'make build' to rebuild the Lua files from Teal sources" \ No newline at end of file diff --git a/src/sentry/core/client.lua b/src/sentry/core/client.lua new file mode 100644 index 0000000..7b4a35f --- /dev/null +++ b/src/sentry/core/client.lua @@ -0,0 +1,7 @@ +local Client = {} + +function Client:new(options) + print("client:new") +end + +return Client \ No newline at end of file diff --git a/src/sentry/environments/default.lua b/src/sentry/environments/default.lua new file mode 100644 index 0000000..e69de29 diff --git a/src/sentry/init.lua b/src/sentry/init.lua new file mode 100644 index 0000000..740478a --- /dev/null +++ b/src/sentry/init.lua @@ -0,0 +1,114 @@ +-- If 'debug' is available (not everywhere, e.g: Roblox), use it to make 'require's call relative to local directory + +local function this_dir() + if type(debug) == "table" and type(debug.getinfo) == "function" then + local info = debug.getinfo(1, "S") + if info and type(info.source) == "string" and info.source:sub(1,1) == "@" then + -- handle / and \ (Windows) + return info.source:match("@(.*/)") + or info.source:match("@(.*\\)") + end + end + return nil +end + +local dir = this_dir() +if dir then + package.path = dir .. "?.lua;" .. dir .. "?/init.lua;" .. package.path +end + +print("1") +local ok, info = pcall(debug.getinfo, 1, "S") +print("info.source" .. info.source) +print("info.source outside pcall" .. debug.getinfo(1, "S").source) +print("getinfo match" .. debug.getinfo(1, "S").source:match("@(.*/)")) + +if ok and info and info.source:sub(1,1) == "@" then + print("2") + local dir = info.source:match("@(.*/)") + package.path = dir .. "?.lua;" .. dir .. "?/init.lua;" .. package.path +end + +local Client = require("core.client"); + +local sentry = {} + +local function init(options) + print("init") + sentry._client = Client:new(options) +end +local function capture_message(message) + if not sentry._client then + print("Sentry SDK has not been initialized") + return + end + print("capture_message") +end +local function capture_exception(exception) + if not sentry._client then + print("Sentry SDK has not been initialized") + return + end + print("capture_exception") +end +local function add_breadcrumb(exception) + if not sentry._client then + print("Sentry SDK has not been initialized") + return + end + print("add_breadcrumb") +end +local function with_scope(callback) + if not sentry._client then + print("Sentry SDK has not been initialized. Executing callback for program continuity but no error handling will be active") + return + end + -- local scope = scope:clone() + callback() + print("with_scope") +end +local function set_tag(key, value) + if not sentry._client then + print("Sentry SDK has not been initialized") + return + end + print("set_tag") +end +local function set_extra(key, value) + if not sentry._client then + print("Sentry SDK has not been initialized") + return + end + print("set_extra") +end +local function set_user(key, value) + if not sentry._client then + print("Sentry SDK has not been initialized") + return + end + print("set_user") +end +local function flush(key, value) + if not sentry._client then + print("Sentry SDK has not been initialized") + return + end + print("flush") +end +local function close() + print("close - calling flush") + flush() +end + +sentry.init = init; +sentry.capture_message = capture_message; +sentry.capture_exception = capture_exception; +sentry.add_breadcrumb = add_breadcrumb; +sentry.with_scope = with_scope; +sentry.set_tag = set_tag; +sentry.set_extra = set_extra; +sentry.set_user = set_user; +sentry.flush = flush; +sentry.close = close; + +return sentry \ No newline at end of file From 2a210d869a93b245679dba081d4271fbf171dd07 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sat, 23 Aug 2025 22:02:09 -0400 Subject: [PATCH 03/23] platform detection --- src/sentry/core/client.lua | 10 +++ src/sentry/core/diagnostic_logger.lua | 11 +++ src/sentry/core/scope.lua | 41 +++++++++++ src/sentry/core/transport.lua | 14 ++++ src/sentry/environments/default.lua | 0 src/sentry/init.lua | 82 ++++++++++----------- src/sentry/platforms/defold.lua | 44 ++++++++++++ src/sentry/platforms/init.lua | 31 ++++++++ src/sentry/platforms/love2d.lua | 100 ++++++++++++++++++++++++++ src/sentry/platforms/lua.lua | 69 ++++++++++++++++++ src/sentry/platforms/openresty.lua | 32 +++++++++ src/sentry/platforms/roblox.lua | 31 ++++++++ 12 files changed, 419 insertions(+), 46 deletions(-) create mode 100644 src/sentry/core/diagnostic_logger.lua create mode 100644 src/sentry/core/scope.lua create mode 100644 src/sentry/core/transport.lua delete mode 100644 src/sentry/environments/default.lua create mode 100644 src/sentry/platforms/defold.lua create mode 100644 src/sentry/platforms/init.lua create mode 100644 src/sentry/platforms/love2d.lua create mode 100644 src/sentry/platforms/lua.lua create mode 100644 src/sentry/platforms/openresty.lua create mode 100644 src/sentry/platforms/roblox.lua diff --git a/src/sentry/core/client.lua b/src/sentry/core/client.lua index 7b4a35f..372389d 100644 --- a/src/sentry/core/client.lua +++ b/src/sentry/core/client.lua @@ -1,7 +1,17 @@ +local diagnostic_logger = require("core.diagnostic_logger") local Client = {} +Client.__index = Client function Client:new(options) print("client:new") + if not options then + diagnostic_logger.warn("Cannot create a Sentry client without options.") + return nil + end + local client = setmetatable({ + options = options, + }, {__index = self}) + return client end return Client \ No newline at end of file diff --git a/src/sentry/core/diagnostic_logger.lua b/src/sentry/core/diagnostic_logger.lua new file mode 100644 index 0000000..a1e11c2 --- /dev/null +++ b/src/sentry/core/diagnostic_logger.lua @@ -0,0 +1,11 @@ +local diagnostic_logger = {} + +diagnostic_logger.debug = function(message) + print("[Sentry] " .. message) +end + +diagnostic_logger.warn = function(message) + warn("[Sentry] " .. message) +end + +return diagnostic_logger \ No newline at end of file diff --git a/src/sentry/core/scope.lua b/src/sentry/core/scope.lua new file mode 100644 index 0000000..98729d8 --- /dev/null +++ b/src/sentry/core/scope.lua @@ -0,0 +1,41 @@ +local Scope = {} +Scope.__index = Scope + +Scope.user = {} +Scope.tags = {} +Scope.extra = {} +Scope.contexts = {} +Scope.breadcrumbs = {} + +function Scope:new() + print("scope:new") + local scope = setmetatable({ + -- + }, {__index = self}) + return scope +end + +function Scope:clone() + local new_scope = Scope:new() + for k, v in pairs(self.user) do + new_scope.user[k] = v + end +end + +function Scope:add_breadcrumb(breadcrumb) + local crumb = { + -- TODO: os.time won't work on Roblox? + -- timestamp = os.time(), + message = breadcrumb.message or "", + category = breadcrumb.category or "default", + level = breadcrumb.level or "info", + data = breadcrumb.data -- or {} + } + table.insert(self.breadcrumbs, crumb) + + while #self.breadcrumbs > self.max_breadcrumbs do + table.remove(self.breadcrumbs, 1) + end +end + +return Scope \ No newline at end of file diff --git a/src/sentry/core/transport.lua b/src/sentry/core/transport.lua new file mode 100644 index 0000000..53b2541 --- /dev/null +++ b/src/sentry/core/transport.lua @@ -0,0 +1,14 @@ +-- example calling function: +local ok, status, body, headers = net.http_post( + "https://httpbin.org/post", + '{"hello":"world"}', + {["Content-Type"]="application/json"}, + { timeout_ms = 5000 } +) + +if not ok then + print("POST failed:", status) +else + print("Status:", status) + print("Body:", body) +end \ No newline at end of file diff --git a/src/sentry/environments/default.lua b/src/sentry/environments/default.lua deleted file mode 100644 index e69de29..0000000 diff --git a/src/sentry/init.lua b/src/sentry/init.lua index 740478a..cd12942 100644 --- a/src/sentry/init.lua +++ b/src/sentry/init.lua @@ -1,103 +1,93 @@ -- If 'debug' is available (not everywhere, e.g: Roblox), use it to make 'require's call relative to local directory - -local function this_dir() - if type(debug) == "table" and type(debug.getinfo) == "function" then - local info = debug.getinfo(1, "S") +if type(debug) == "table" and type(debug.getinfo) == "function" then +local info = debug.getinfo(1, "S") if info and type(info.source) == "string" and info.source:sub(1,1) == "@" then - -- handle / and \ (Windows) - return info.source:match("@(.*/)") - or info.source:match("@(.*\\)") + -- handle / and \ (Windows) + local local_dir = info.source:match("@(.*/)") or info.source:match("@(.*\\)") + if local_dir then + package.path = local_dir .. "?.lua;" .. local_dir .. "?/init.lua;" .. package.path + end end - end - return nil -end - -local dir = this_dir() -if dir then - package.path = dir .. "?.lua;" .. dir .. "?/init.lua;" .. package.path end -print("1") -local ok, info = pcall(debug.getinfo, 1, "S") -print("info.source" .. info.source) -print("info.source outside pcall" .. debug.getinfo(1, "S").source) -print("getinfo match" .. debug.getinfo(1, "S").source:match("@(.*/)")) - -if ok and info and info.source:sub(1,1) == "@" then - print("2") - local dir = info.source:match("@(.*/)") - package.path = dir .. "?.lua;" .. dir .. "?/init.lua;" .. package.path -end +local platform = require("platforms")() +print(platform.name, platform.timestamp()) local Client = require("core.client"); +local Scope = require("core.scope"); +local logger = require("core.diagnostic_logger"); local sentry = {} local function init(options) - print("init") + logger.debug("init") sentry._client = Client:new(options) + sentry._scope = Scope:new() end local function capture_message(message) if not sentry._client then - print("Sentry SDK has not been initialized") + logger.debug("Sentry SDK has not been initialized") return end - print("capture_message") + logger.debug("capture_message") end local function capture_exception(exception) if not sentry._client then - print("Sentry SDK has not been initialized") + logger.debug("Sentry not initialized. Call sentry.init() first.") return end - print("capture_exception") + logger.debug("capture_exception") end local function add_breadcrumb(exception) if not sentry._client then - print("Sentry SDK has not been initialized") + logger.debug("Sentry not initialized. Call sentry.init() first.") return end - print("add_breadcrumb") + logger.debug("add_breadcrumb") end local function with_scope(callback) if not sentry._client then - print("Sentry SDK has not been initialized. Executing callback for program continuity but no error handling will be active") + logger.debug("Sentry SDK has not been initialized. Executing callback for program continuity but no error handling will be active") return end - -- local scope = scope:clone() - callback() - print("with_scope") + local scope = sentry._scope:clone() + callback(scope) + logger.debug("with_scope") end local function set_tag(key, value) if not sentry._client then - print("Sentry SDK has not been initialized") + logger.debug("Sentry not initialized. Call sentry.init() first.") return end - print("set_tag") + logger.debug("set_tag") end local function set_extra(key, value) if not sentry._client then - print("Sentry SDK has not been initialized") + logger.debug("Sentry not initialized. Call sentry.init() first.") return end - print("set_extra") + logger.debug("set_extra") end local function set_user(key, value) if not sentry._client then - print("Sentry SDK has not been initialized") + logger.debug("Sentry not initialized. Call sentry.init() first.") return end - print("set_user") + logger.debug("set_user") end local function flush(key, value) if not sentry._client then - print("Sentry SDK has not been initialized") + logger.debug("Sentry not initialized. Call sentry.init() first.") return end - print("flush") + logger.debug("flush") end local function close() - print("close - calling flush") - flush() + if not sentry._client then + logger.debug("Sentry not initialized. Call sentry.init() first.") + return + end + logger.debug("close") end sentry.init = init; diff --git a/src/sentry/platforms/defold.lua b/src/sentry/platforms/defold.lua new file mode 100644 index 0000000..b76e975 --- /dev/null +++ b/src/sentry/platforms/defold.lua @@ -0,0 +1,44 @@ +-- NOTE: This module depends on the Defold environment. It should only be loaded after verifying it is indeed on Defold + +local function tz_suffix(t) + local z = os.date("%z", t or os.time()) or "+0000" + return string.format("%s%s:%s", z:sub(1,1), z:sub(2,3), z:sub(4,5)) +end + +local M = { name = "defold" } + +function M.timestamp() + local sec = os.time() + local ms = 0 -- Defold core Lua lacks a standard ms wall clock + local d = os.date("*t", sec) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", + d.year,d.month,d.day,d.hour,d.min,d.sec,ms,tz_suffix(sec)) +end + +function M.http_post(url, body, headers, opts) + headers = headers or {} + local result, done = { ok=false }, false + +---@diagnostic disable-next-line: undefined-global + http.request(url, "POST", function(self, id, response) + result.ok = (response.status >= 200 and response.status < 400) + result.status = response.status + result.body = response.response + result.headers = response.headers + done = true + end, headers, body or "") + + -- block until done (must be inside coroutine or engine update loop) + while not done do coroutine.yield() end + if not result.ok then return nil, tostring(result.status or "request failed") end + return true, result.status, result.body, result.headers +end + +function M.sleep(ms) + local t0 = os.clock() + local target = (ms or 0)/1000 + while os.clock() - t0 < target do coroutine.yield() end + return true +end + +return M diff --git a/src/sentry/platforms/init.lua b/src/sentry/platforms/init.lua new file mode 100644 index 0000000..bb870fa --- /dev/null +++ b/src/sentry/platforms/init.lua @@ -0,0 +1,31 @@ +local M = nil -- will cache the chosen adapter + +local function detect() + -- Roblox: DateTime + HttpService/Game presence is a good signal + if rawget(_G, "DateTime") and rawget(_G, "game") then + return require("platforms.roblox") + end + + -- Defold: global 'http' (http.request), 'sys' module, and go module exist + if rawget(_G, "http") and rawget(_G, "sys") and rawget(_G, "go") then + return require("platforms.defold") + end + + -- LÖVE: global 'love' with version + if rawget(_G, "love") and type(love) == "table" and love._version then + return require("platforms.love2d") + end + + -- Fallback to standard Lua + return require("platforms.lua") +end + +local function get() + if not M then M = detect() end + return M +end + +return setmetatable({}, { + __index = function(_, k) return get()[k] end, -- proxy functions/fields + __call = function() return get() end, -- allow require(... )() to fetch table +}) diff --git a/src/sentry/platforms/love2d.lua b/src/sentry/platforms/love2d.lua new file mode 100644 index 0000000..51cd4b5 --- /dev/null +++ b/src/sentry/platforms/love2d.lua @@ -0,0 +1,100 @@ +---@diagnostic disable: undefined-global +-- NOTE: This module depends on the LǑVE environment. It should only be loaded after verifying it is indeed on love2d + +local M = { name = "love2d" } + +-- Required imports +local function safe_require(n) local ok, m = pcall(require, n); return ok and m or nil end +local https = safe_require("ssl.https") +if not https then error("https not available (install LuaSec: ssl.https)") end +local ltn12 = safe_require("ltn12") +if not ltn12 then error("ltn12 not available (install ltn12)") end + +local function tz_suffix(t) + local z = os.date("%z", t or os.time()) or "+0000" + assert(type(z) == "string") + return string.format("%s%s:%s", z:sub(1,1), z:sub(2,3), z:sub(4,5)) +end + +-- Try to grab LuaSocket once +local socket = safe_require("socket") + +-- Capture baseline for love.timer blending if needed +local base_wall = os.time() +local base_mono = (love and love.timer and love.timer.getTime and love.timer.getTime()) or 0 + +-- Choose implementation once +local timestamp_impl +if socket and type(socket.gettime) == "function" then + -- Best: LuaSocket with ms precision + timestamp_impl = function() + local now = socket.gettime() + local sec = math.floor(now) + local ms = math.floor((now - sec) * 1000 + 0.5) + if ms == 1000 then ms, sec = 0, sec + 1 end + local d = os.date("*t", sec) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", + d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) + end + +elseif love and love.timer and love.timer.getTime then + -- Fallback: blend os.time() with love.timer high-res clock + timestamp_impl = function() + local elapsed = love.timer.getTime() - base_mono + local now = base_wall + elapsed + local sec = math.floor(now) + local ms = math.floor((now - sec) * 1000 + 0.5) + if ms == 1000 then ms, sec = 0, sec + 1 end + local d = os.date("*t", sec) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", + d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) + end + +else + -- Last resort: os.time() only + timestamp_impl = function() + local sec, ms = os.time(), 0 + local d = os.date("*t", sec) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", + d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) + end +end + +-- Public API +function M.timestamp() + return timestamp_impl() +end + +local function set_timeout(lib, ms) + if lib and ms then lib.TIMEOUT = math.max(0.001, ms/1000) end +end + +local function request_with_optional_headers(lib, url, headers, body, timeout_ms) + set_timeout(lib, timeout_ms) + headers = headers or {} + body = body or "" + + if ltn12 then + local chunks = {} + local ok, code, resp_headers = lib.request{ + url = url, + method = "POST", + headers = headers, + source = ltn12.source.string(body), + sink = ltn12.sink.table(chunks), + } + if not ok then return nil, tostring(code or "request failed") end + return true, tonumber(code), table.concat(chunks), resp_headers + end + + local resp_body, code, resp_headers = lib.request(url, body) + if not resp_body then return nil, tostring(code or "request failed") end + return true, tonumber(code), resp_body, resp_headers +end + +function M.http_post(url, body, headers, opts) + opts = opts or {} + return request_with_optional_headers(https, url, headers, body, opts.timeout_ms) +end + +return M diff --git a/src/sentry/platforms/lua.lua b/src/sentry/platforms/lua.lua new file mode 100644 index 0000000..9857536 --- /dev/null +++ b/src/sentry/platforms/lua.lua @@ -0,0 +1,69 @@ +-- NOTE: This module depends on the standard Lua environment. It should only be loaded after verifying it is indeed on standard Lua + +local function tz_suffix(t) + local z = os.date("%z", t or os.time()) or "+0000" + assert(type(z) == "string") + return string.format("%s%s:%s", z:sub(1,1), z:sub(2,3), z:sub(4,5)) +end + +local function safe_require(n) local ok, m = pcall(require, n); return ok and m or nil end +local https = safe_require("ssl.https") +if not https then error("https not available (install LuaSec: ssl.https)") end + +local ltn12 = safe_require("ltn12") + +local M = { name = "lua" } + +function M.timestamp() + local now, sec, ms + if socket_ok and type(socket.gettime) == "function" then + now = socket.gettime(); sec = now // 1; ms = math.floor((now - sec)*1000 + 0.5) + if ms == 1000 then ms = 0; sec = sec + 1 end + else + sec, ms = os.time(), 0 + end + local d = os.date("*t", sec) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", + d.year,d.month,d.day,d.hour,d.min,d.sec,ms,tz_suffix(sec)) +end + + +local function set_timeout(lib, ms) + if lib and ms then lib.TIMEOUT = math.max(0.001, ms/1000) end +end + +local function request_with_optional_headers(lib, url, method, headers, body, timeout_ms) + set_timeout(lib, timeout_ms) + headers = headers or {} + body = body or "" + + if ltn12 then + local chunks = {} + local ok, code, resp_headers, status = lib.request{ + url = url, + method = method, + headers = headers, + source = ltn12.source.string(body), + sink = ltn12.sink.table(chunks), + } + if not ok then return nil, tostring(code or "request failed") end + return true, tonumber(code), table.concat(chunks), resp_headers + end + + -- No ltn12: reject custom headers; allow body via 2-arg form + for _ in pairs(headers) do + return nil, "custom headers require ltn12 (install ltn12)" + end + + local resp_body, code, resp_headers = lib.request(url, body) + if not resp_body then return nil, tostring(code or "request failed") end + return true, tonumber(code), resp_body, resp_headers +end + +function M.http_post(url, body, headers, opts) + opts = opts or {} + local scheme = url:match("^([%a][%w+.-]*)://") or error("Only https connection is supported.") + return request_with_optional_headers(https, url, "POST", headers, body, opts.timeout_ms) +end + +return M diff --git a/src/sentry/platforms/openresty.lua b/src/sentry/platforms/openresty.lua new file mode 100644 index 0000000..0ae4001 --- /dev/null +++ b/src/sentry/platforms/openresty.lua @@ -0,0 +1,32 @@ +local M = { name = "openresty" } + +local function safe_require(n) local ok, m = pcall(require, n); return ok and m or nil end +local http = safe_require("resty.http") +if not http then error("http not available") end + +function M.http_post(url, body, headers, opts) + if not http then return nil, "resty.http not available" end + local httpc = http.new() + if opts and opts.timeout_ms then httpc:set_timeout(opts.timeout_ms) end + local res, err = httpc:request_uri(url, { + method = "POST", + body = body or "", + headers = headers or {}, + keepalive = not (opts and opts.keepalive == false), + }) + if not res then return nil, err end + return true, res.status, res.body, res.headers +end + +function M.timestamp() +---@diagnostic disable-next-line: undefined-global + local now = ngx.now() -- seconds + fractional + local sec = math.floor(now) + local ms = math.floor((now - sec) * 1000 + 0.5) + if ms == 1000 then ms = 0; sec = sec + 1 end + local d = os.date("!*t", sec) -- UTC + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", + d.year,d.month,d.day,d.hour,d.min,d.sec,ms) +end + +return M diff --git a/src/sentry/platforms/roblox.lua b/src/sentry/platforms/roblox.lua new file mode 100644 index 0000000..58e9a06 --- /dev/null +++ b/src/sentry/platforms/roblox.lua @@ -0,0 +1,31 @@ +-- NOTE: This module depends on the Roblox environment. It should only be loaded after verifying it is indeed on Roblox +---@diagnostic disable: undefined-global + +local HttpService = game:GetService("HttpService") + +local M = { name = "roblox" } + +function M.timestamp() + -- "2025-08-23T21:05:17.123Z" + return DateTime.now():ToIsoDate() +end + +function M.http_post(url, body, headers, _) + local ok, res = pcall(function() + return HttpService:RequestAsync({ + Url = url, + Method = "POST", + Headers = headers or {}, + Body = body or "", + }) + end) + if not ok then return nil, tostring(res) end + return res.Success, res.StatusCode, res.Body, res.Headers +end + +function M.sleep(ms) + task.wait((ms or 0) / 1000) + return true +end + +return M From 4e76505c59f0f6bcd4aee8a2115257b535627db8 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sat, 23 Aug 2025 23:03:21 -0400 Subject: [PATCH 04/23] debugger --- src/sentry/core/diagnostic_logger.lua | 5 +++ src/sentry/core/scope.lua | 3 +- src/sentry/init.lua | 10 ++++- src/sentry/platforms/init.lua | 57 ++++++++++++++++++++------- src/sentry/platforms/lua.lua | 26 ++++++++++++ 5 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/sentry/core/diagnostic_logger.lua b/src/sentry/core/diagnostic_logger.lua index a1e11c2..35b6a09 100644 --- a/src/sentry/core/diagnostic_logger.lua +++ b/src/sentry/core/diagnostic_logger.lua @@ -8,4 +8,9 @@ diagnostic_logger.warn = function(message) warn("[Sentry] " .. message) end +diagnostic_logger.error = function(message) + warn("[Sentry] ERROR: " .. message, 2) + -- error("[Sentry] " .. message, 2) +end + return diagnostic_logger \ No newline at end of file diff --git a/src/sentry/core/scope.lua b/src/sentry/core/scope.lua index 98729d8..854d7b4 100644 --- a/src/sentry/core/scope.lua +++ b/src/sentry/core/scope.lua @@ -10,7 +10,7 @@ Scope.breadcrumbs = {} function Scope:new() print("scope:new") local scope = setmetatable({ - -- + max_breadcrumbs = 100 }, {__index = self}) return scope end @@ -20,6 +20,7 @@ function Scope:clone() for k, v in pairs(self.user) do new_scope.user[k] = v end + return new_scope end function Scope:add_breadcrumb(breadcrumb) diff --git a/src/sentry/init.lua b/src/sentry/init.lua index cd12942..bff1009 100644 --- a/src/sentry/init.lua +++ b/src/sentry/init.lua @@ -50,9 +50,15 @@ local function with_scope(callback) logger.debug("Sentry SDK has not been initialized. Executing callback for program continuity but no error handling will be active") return end - local scope = sentry._scope:clone() - callback(scope) + logger.debug("with_scope") + local new_scope = sentry._scope:clone() + + local success, result = pcall(callback, new_scope) + + if not success then + logger.error("callback failed to run through with_scope: " .. result) + end end local function set_tag(key, value) if not sentry._client then diff --git a/src/sentry/platforms/init.lua b/src/sentry/platforms/init.lua index bb870fa..e53a577 100644 --- a/src/sentry/platforms/init.lua +++ b/src/sentry/platforms/init.lua @@ -1,31 +1,58 @@ local M = nil -- will cache the chosen adapter local function detect() - -- Roblox: DateTime + HttpService/Game presence is a good signal - if rawget(_G, "DateTime") and rawget(_G, "game") then + -- OpenResty: ngx present with config +---@diagnostic disable-next-line: undefined-global + if rawget(_G, "ngx") and ngx and ngx.config then + return require("platforms.openresty") + end + -- Roblox: DateTime + HttpService/Game presence is a good signal + if rawget(_G, "DateTime") and rawget(_G, "game") then return require("platforms.roblox") - end + end - -- Defold: global 'http' (http.request), 'sys' module, and go module exist - if rawget(_G, "http") and rawget(_G, "sys") and rawget(_G, "go") then + -- Defold: global 'http' (http.request), 'sys' module, and go module exist + if rawget(_G, "http") and rawget(_G, "sys") and rawget(_G, "go") then return require("platforms.defold") - end + end - -- LÖVE: global 'love' with version - if rawget(_G, "love") and type(love) == "table" and love._version then + -- LÖVE: global 'love' with version + ---@diagnostic disable-next-line: undefined-global + if rawget(_G, "love") and type(love) == "table" and love._version then return require("platforms.love2d") - end + end - -- Fallback to standard Lua - return require("platforms.lua") + -- Fallback to standard Lua + return require("platforms.lua") +end + +local function debug(err) + print("Failed loading platform: " .. err) +---@diagnostic disable-next-line: undefined-global + print(_VERSION, jit and jit.version or "no-jit") + print("cwd:", (io.popen and io.popen("pwd"):read("*l")) or "") + print("package.path =", package.path) + print("package.cpath =", package.cpath) + + local ok1, ssl = pcall(require, "ssl") + print("require ssl =", ok1, ok1 and (ssl._VERSION or "") or ssl) + + local ok2, https = pcall(require, "ssl.https") + print("require ssl.https =", ok2, ok2 and "ok" or https) end local function get() - if not M then M = detect() end - return M + if not M then + local ok, val = xpcall(detect, debug) + if not ok then error("failed to load Sentry") end + M = val + end + assert(M.http_post, "http_post missing on " .. M.name) + assert(M.timestamp, "timestamp missing on " .. M.name) + return M end return setmetatable({}, { - __index = function(_, k) return get()[k] end, -- proxy functions/fields - __call = function() return get() end, -- allow require(... )() to fetch table + __index = function(_, k) return get()[k] end, -- proxy functions/fields + __call = function() return get() end, -- allow require(... )() to fetch table }) diff --git a/src/sentry/platforms/lua.lua b/src/sentry/platforms/lua.lua index 9857536..71fabd2 100644 --- a/src/sentry/platforms/lua.lua +++ b/src/sentry/platforms/lua.lua @@ -1,5 +1,31 @@ -- NOTE: This module depends on the standard Lua environment. It should only be loaded after verifying it is indeed on standard Lua +local function ensure_https() + local ok, https = pcall(require, "ssl.https") + if ok then return https end + + -- Hacks to get the debugger to work on a mac + local lr_path = "~/.luarocks/share/lua/5.4/?.lua;~/.luarocks/share/lua/5.4/?/init.lua;/opt/homebrew/share/lua/5.4/?.lua;/opt/homebrew/share/lua/5.4/?/init.lua" + local lr_cpath = "~/.luarocks/lib/lua/5.4/?.so;/opt/homebrew/lib/lua/5.4/?.so" + + if not package.path:find("/.luarocks/share/lua/5.4", 1, true) then + package.path = package.path .. ";" .. lr_path + end + if not package.cpath:find("/.luarocks/lib/lua/5.4", 1, true) then + package.cpath = package.cpath .. ";" .. lr_cpath + end + + local ok2, https2 = pcall(require, "ssl.https") + if ok2 then return https2 end + + -- Last resort: print why (helps when the debugger’s runtime/ABI mismatches) + local msg = ("Cannot load ssl.https.\npackage.path=%s\npackage.cpath=%s"):format(package.path, package.cpath) + io.stderr:write(msg.."\n") + return nil +end + +ensure_https() + local function tz_suffix(t) local z = os.date("%z", t or os.time()) or "+0000" assert(type(z) == "string") From 911349a913260089612d7ad8d2b8553f7a535c55 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sat, 23 Aug 2025 23:15:09 -0400 Subject: [PATCH 05/23] deps listed --- README.md | 2 +- roblox.json | 2 +- sentry-0.0.6-1.rockspec => sentry-0.0.7-1.rockspec | 12 ++++++++---- src/sentry/core/version.lua | 6 ++++++ 4 files changed, 16 insertions(+), 6 deletions(-) rename sentry-0.0.6-1.rockspec => sentry-0.0.7-1.rockspec (69%) create mode 100644 src/sentry/core/version.lua diff --git a/README.md b/README.md index 2bd73d6..d49f510 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ local sentry = require("sentry") sentry.init({ dsn = "https://your-dsn@sentry.io/project-id", environment = "production", - release = "0.0.6" + release = "0.0.7" }) -- Capture a message diff --git a/roblox.json b/roblox.json index 5dd2df3..90efb8e 100644 --- a/roblox.json +++ b/roblox.json @@ -1,6 +1,6 @@ { "name": "sentry", - "version": "0.0.6", + "version": "0.0.7", "description": "Sentry SDK for Roblox - Error tracking and monitoring", "author": "bruno-garcia", "license": "MIT", diff --git a/sentry-0.0.6-1.rockspec b/sentry-0.0.7-1.rockspec similarity index 69% rename from sentry-0.0.6-1.rockspec rename to sentry-0.0.7-1.rockspec index e9add07..202877c 100644 --- a/sentry-0.0.6-1.rockspec +++ b/sentry-0.0.7-1.rockspec @@ -1,9 +1,9 @@ rockspec_format = "3.0" package = "sdk" -version = "0.0.6-1" +version = "0.0.7-1" source = { url = "git+https://github.com/getsentry/sentry-lua.git", - tag = "0.0.6" + tag = "0.0.7" } description = { summary = "Sentry SDK for Lua", @@ -15,8 +15,12 @@ description = { } dependencies = { "lua >= 5.1", - "luasocket" + "luasocket >= 3.0", + "luasec >= 1.0", } build = { - type = "command", + type = "builtin", + modules = { + ["sentry"] = "src/sentry/init.lua", + } } \ No newline at end of file diff --git a/src/sentry/core/version.lua b/src/sentry/core/version.lua new file mode 100644 index 0000000..8f07a97 --- /dev/null +++ b/src/sentry/core/version.lua @@ -0,0 +1,6 @@ +-- Sentry Lua SDK Version +-- This file is automatically updated by the bump-version script + +local VERSION = "0.0.7" + +return VERSION \ No newline at end of file From e3a42b499b739ae9821e0527d14d9035d74c508a Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sat, 23 Aug 2025 23:46:40 -0400 Subject: [PATCH 06/23] async transport --- src/sentry/core/client.lua | 21 ++++++- src/sentry/core/transport.lua | 98 +++++++++++++++++++++++++----- src/sentry/init.lua | 2 +- src/sentry/platforms/defold.lua | 11 ++++ src/sentry/platforms/love2d.lua | 7 +++ src/sentry/platforms/lua.lua | 7 +++ src/sentry/platforms/openresty.lua | 10 +++ src/sentry/platforms/roblox.lua | 20 ++++++ 8 files changed, 160 insertions(+), 16 deletions(-) diff --git a/src/sentry/core/client.lua b/src/sentry/core/client.lua index 372389d..54f84dc 100644 --- a/src/sentry/core/client.lua +++ b/src/sentry/core/client.lua @@ -1,17 +1,36 @@ local diagnostic_logger = require("core.diagnostic_logger") +local Transport = require("core.transport") local Client = {} Client.__index = Client function Client:new(options) - print("client:new") if not options then diagnostic_logger.warn("Cannot create a Sentry client without options.") return nil end + if not options.dsn then + diagnostic_logger.warn("Cannot create a Sentry client without DSN.") + return nil + end local client = setmetatable({ options = options, + transport = Transport:new(options.dsn, options.transport_options), }, {__index = self}) return client end +function Client:capture_message(message) + if not self.transport then + diagnostic_logger.error("Transport not initialized") + return nil + end + + local event = { + message = message, + level = "info", + } + + return self.transport:send_event(event) +end + return Client \ No newline at end of file diff --git a/src/sentry/core/transport.lua b/src/sentry/core/transport.lua index 53b2541..b09c55c 100644 --- a/src/sentry/core/transport.lua +++ b/src/sentry/core/transport.lua @@ -1,14 +1,84 @@ --- example calling function: -local ok, status, body, headers = net.http_post( - "https://httpbin.org/post", - '{"hello":"world"}', - {["Content-Type"]="application/json"}, - { timeout_ms = 5000 } -) - -if not ok then - print("POST failed:", status) -else - print("Status:", status) - print("Body:", body) -end \ No newline at end of file +local platform = require("platforms")() +local logger = require("core.diagnostic_logger") + +local Transport = {} +Transport.__index = Transport + +function Transport:new(dsn, options) + options = options or {} + local transport = setmetatable({ + dsn = dsn, + queue = {}, + max_queue_size = options.max_queue_size or 30, + max_concurrent = options.max_concurrent or 5, + timeout_ms = options.timeout_ms or 5000, + processing = false, + active_requests = 0, + }, {__index = self}) + return transport +end + +function Transport:_process_queue() + if self.processing or #self.queue == 0 or self.active_requests >= self.max_concurrent then + return + end + + self.processing = true + + local event = table.remove(self.queue, 1) + if event then + self.active_requests = self.active_requests + 1 + platform.http_post_async( + self.dsn, + self:_serialize_event(event), + { + ["Content-Type"] = "application/json", + ["User-Agent"] = "sentry-lua/1.0.0", + }, + { timeout_ms = self.timeout_ms }, + function(ok, status, body) + self.active_requests = self.active_requests - 1 + if not ok then + local msg = "Failed to send event to Sentry: " .. tostring(status) + if body and body ~= "" then + msg = msg .. ", response: " .. tostring(body) + end + logger.error(msg) + elseif status < 200 or status >= 300 then + local msg = "Sentry responded with error status: " .. tostring(status) + if body and body ~= "" then + msg = msg .. ", response: " .. tostring(body) + end + logger.error(msg) + else + logger.debug("Event sent to Sentry, status: " .. tostring(status)) + end + self:_process_queue() + end + ) + end + + self.processing = false + + if #self.queue > 0 and self.active_requests < self.max_concurrent then + self:_process_queue() + end +end + +function Transport:_serialize_event(event) + local serialized = '{"message":"' .. (event.message or "") .. '","timestamp":"' .. platform.timestamp() .. '"}' + return serialized +end + +function Transport:send_event(event) + if #self.queue >= self.max_queue_size then + logger.warn("Queue full, dropping event") + return false + end + + table.insert(self.queue, event) + self:_process_queue() + return true +end + +return Transport \ No newline at end of file diff --git a/src/sentry/init.lua b/src/sentry/init.lua index bff1009..6ef5b62 100644 --- a/src/sentry/init.lua +++ b/src/sentry/init.lua @@ -29,7 +29,7 @@ local function capture_message(message) logger.debug("Sentry SDK has not been initialized") return end - logger.debug("capture_message") + return sentry._client:capture_message(message) end local function capture_exception(exception) if not sentry._client then diff --git a/src/sentry/platforms/defold.lua b/src/sentry/platforms/defold.lua index b76e975..1315629 100644 --- a/src/sentry/platforms/defold.lua +++ b/src/sentry/platforms/defold.lua @@ -34,6 +34,17 @@ function M.http_post(url, body, headers, opts) return true, result.status, result.body, result.headers end +function M.http_post_async(url, body, headers, _, callback) + headers = headers or {} +---@diagnostic disable-next-line: undefined-global + http.request(url, "POST", function(self, id, response) + if callback then + local ok = (response.status >= 200 and response.status < 400) + callback(ok, response.status, response.response) + end + end, headers, body or "") +end + function M.sleep(ms) local t0 = os.clock() local target = (ms or 0)/1000 diff --git a/src/sentry/platforms/love2d.lua b/src/sentry/platforms/love2d.lua index 51cd4b5..2569188 100644 --- a/src/sentry/platforms/love2d.lua +++ b/src/sentry/platforms/love2d.lua @@ -97,4 +97,11 @@ function M.http_post(url, body, headers, opts) return request_with_optional_headers(https, url, headers, body, opts.timeout_ms) end +function M.http_post_async(url, body, headers, opts, callback) + local ok, status, resp_body, resp_headers = M.http_post(url, body, headers, opts) + if callback then + callback(ok, status, resp_body) + end +end + return M diff --git a/src/sentry/platforms/lua.lua b/src/sentry/platforms/lua.lua index 71fabd2..7a3bf59 100644 --- a/src/sentry/platforms/lua.lua +++ b/src/sentry/platforms/lua.lua @@ -92,4 +92,11 @@ function M.http_post(url, body, headers, opts) return request_with_optional_headers(https, url, "POST", headers, body, opts.timeout_ms) end +function M.http_post_async(url, body, headers, opts, callback) + local ok, status, resp_body, resp_headers = M.http_post(url, body, headers, opts) + if callback then + callback(ok, status, resp_body) + end +end + return M diff --git a/src/sentry/platforms/openresty.lua b/src/sentry/platforms/openresty.lua index 0ae4001..0faff77 100644 --- a/src/sentry/platforms/openresty.lua +++ b/src/sentry/platforms/openresty.lua @@ -18,6 +18,16 @@ function M.http_post(url, body, headers, opts) return true, res.status, res.body, res.headers end +function M.http_post_async(url, body, headers, opts, callback) +---@diagnostic disable-next-line: undefined-global + ngx.thread.spawn(function() + local ok, status, resp_body = M.http_post(url, body, headers, opts) + if callback then + callback(ok, status, resp_body) + end + end) +end + function M.timestamp() ---@diagnostic disable-next-line: undefined-global local now = ngx.now() -- seconds + fractional diff --git a/src/sentry/platforms/roblox.lua b/src/sentry/platforms/roblox.lua index 58e9a06..6eaaf18 100644 --- a/src/sentry/platforms/roblox.lua +++ b/src/sentry/platforms/roblox.lua @@ -23,6 +23,26 @@ function M.http_post(url, body, headers, _) return res.Success, res.StatusCode, res.Body, res.Headers end +function M.http_post_async(url, body, headers, _, callback) + task.spawn(function() + local ok, res = pcall(function() + return HttpService:RequestAsync({ + Url = url, + Method = "POST", + Headers = headers or {}, + Body = body or "", + }) + end) + if callback then + if not ok then + callback(false, tostring(res)) + else + callback(res.Success, res.StatusCode, res.Body) + end + end + end) +end + function M.sleep(ms) task.wait((ms or 0) / 1000) return true From f99868f9855f66277c7fb497f3384951f48efff5 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 00:08:01 -0400 Subject: [PATCH 07/23] dsn testing --- .busted | 2 +- spec/dsn_parsing_spec.lua | 236 ++++++++++++++++++++++++++++++++++++++ spec/init.lua | 18 +++ spec/spec_helper.lua | 27 +++++ spec/test_template.lua | 21 ++++ src/sentry/core/dsn.lua | 90 +++++++++++++++ 6 files changed, 393 insertions(+), 1 deletion(-) create mode 100644 spec/dsn_parsing_spec.lua create mode 100644 spec/init.lua create mode 100644 spec/spec_helper.lua create mode 100644 spec/test_template.lua create mode 100644 src/sentry/core/dsn.lua diff --git a/.busted b/.busted index cec06bd..b8be4c6 100644 --- a/.busted +++ b/.busted @@ -2,6 +2,6 @@ return { default = { pattern = "_spec", ROOT = {"spec/"}, - lpath = "src/?.lua;src/?/init.lua;build/?.lua;build/?/init.lua" + lpath = "src/?.lua;src/?/init.lua;src/sentry/?.lua;src/sentry/?/init.lua;build/?.lua;build/?/init.lua" } } \ No newline at end of file diff --git a/spec/dsn_parsing_spec.lua b/spec/dsn_parsing_spec.lua new file mode 100644 index 0000000..695d702 --- /dev/null +++ b/spec/dsn_parsing_spec.lua @@ -0,0 +1,236 @@ +require("spec") + +describe("DSN Parsing", function() + local dsn_utils + + before_each(function() + dsn_utils = require("core.dsn") + end) + + describe("Valid DSN parsing", function() + it("should parse a standard valid DSN with secret", function() + local dsn_string = "https://public_key:secret_key@sentry.io/123456" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("https", dsn.protocol) + assert.are.equal("public_key", dsn.public_key) + assert.are.equal("secret_key", dsn.secret_key) + assert.are.equal("sentry.io", dsn.host) + assert.are.equal(443, dsn.port) + assert.are.equal("/123456", dsn.path) + assert.are.equal("123456", dsn.project_id) + end) + + it("should parse a valid DSN without secret key", function() + local dsn_string = "https://public_key@sentry.io/789" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("https", dsn.protocol) + assert.are.equal("public_key", dsn.public_key) + assert.are.equal("", dsn.secret_key) + assert.are.equal("sentry.io", dsn.host) + assert.are.equal("789", dsn.project_id) + end) + + it("should parse HTTP DSN with default port 80", function() + local dsn_string = "http://public_key@localhost/456" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("http", dsn.protocol) + assert.are.equal("localhost", dsn.host) + assert.are.equal(80, dsn.port) + assert.are.equal("456", dsn.project_id) + end) + + it("should parse DSN with custom port", function() + local dsn_string = "https://public_key@sentry.example.com:9000/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("sentry.example.com", dsn.host) + assert.are.equal(9000, dsn.port) + end) + + it("should parse DSN with path prefix", function() + local dsn_string = "https://public_key@sentry.io/path/to/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("/path/to/123", dsn.path) + assert.are.equal("123", dsn.project_id) + end) + + it("should parse DSN with subdomain", function() + local dsn_string = "https://abc123@org.ingest.sentry.io/456789" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("abc123", dsn.public_key) + assert.are.equal("org.ingest.sentry.io", dsn.host) + assert.are.equal("456789", dsn.project_id) + end) + + it("should handle numeric project IDs correctly", function() + local dsn_string = "https://key@host.com/1234567890" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("1234567890", dsn.project_id) + end) + end) + + describe("Invalid DSN parsing", function() + it("should reject nil DSN", function() + local dsn, error = dsn_utils.parse_dsn(nil) + + assert.is_not_nil(error) + assert.are.equal("DSN is required", error) + end) + + it("should reject empty string DSN", function() + local dsn, error = dsn_utils.parse_dsn("") + + assert.is_not_nil(error) + assert.are.equal("DSN is required", error) + end) + + it("should reject DSN without protocol", function() + local dsn, error = dsn_utils.parse_dsn("public_key@sentry.io/123") + + assert.is_not_nil(error) + assert.are.equal("Invalid DSN format", error) + end) + + it("should reject DSN without public key", function() + local dsn, error = dsn_utils.parse_dsn("https://@sentry.io/123") + + assert.is_not_nil(error) + assert.are.equal("Invalid DSN format", error) + end) + + it("should reject DSN without host", function() + local dsn, error = dsn_utils.parse_dsn("https://public_key@/123") + + assert.is_not_nil(error) + assert.are.equal("Invalid DSN format", error) + end) + + it("should reject DSN without path", function() + local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io") + + assert.is_not_nil(error) + assert.are.equal("Invalid DSN format", error) + end) + + it("should reject DSN without project ID", function() + local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/") + + assert.is_not_nil(error) + assert.are.equal("Could not extract project ID from DSN", error) + end) + + it("should reject DSN with non-numeric project ID", function() + local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/project") + + assert.is_not_nil(error) + assert.are.equal("Could not extract project ID from DSN", error) + end) + + it("should reject malformed URLs", function() + local malformed_dsns = { + "not-a-url", + "://missing-scheme", + "https://", + "https:///path/only", + "ftp://wrong-scheme@host.com/123", + } + + for _, dsn_string in ipairs(malformed_dsns) do + local dsn, error = dsn_utils.parse_dsn(dsn_string) + assert.is_not_nil(error, "Expected error for DSN: " .. dsn_string) + end + end) + + it("should handle invalid port gracefully", function() + -- This tests the port parsing - invalid ports should fallback to default + local dsn_string = "https://key@host.com:invalid/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal(443, dsn.port) -- Should fallback to default HTTPS port + end) + end) + + describe("Auth header building", function() + it("should build auth header with secret key", function() + local dsn = { + public_key = "public123", + secret_key = "secret456" + } + + local header = dsn_utils.build_auth_header(dsn) + + -- Check that all required parts are present (version-agnostic) + assert.is_true(header:find("Sentry sentry_version=7") ~= nil) + assert.is_true(header:find("sentry_key=public123") ~= nil) + assert.is_true(header:find("sentry_secret=secret456") ~= nil) + assert.is_true(header:find("sentry_client=sentry%-lua/") ~= nil) -- Version-agnostic check + end) + + it("should build auth header without secret key", function() + local dsn = { + public_key = "public123", + secret_key = "" + } + + local header = dsn_utils.build_auth_header(dsn) + + -- Should not include secret + assert.is_true(header:find("sentry_key=public123") ~= nil) + assert.is_true(header:find("sentry_secret") == nil) + end) + + it("should build auth header with nil secret key", function() + local dsn = { + public_key = "public123", + secret_key = nil + } + + local header = dsn_utils.build_auth_header(dsn) + + -- Should not include secret + assert.is_true(header:find("sentry_key=public123") ~= nil) + assert.is_true(header:find("sentry_secret") == nil) + end) + end) + + describe("Edge cases and security", function() + it("should handle special characters in keys", function() + local dsn_string = "https://key-with.dots_and-dashes@host.com/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("key-with.dots_and-dashes", dsn.public_key) + end) + + it("should handle IPv4 addresses", function() + local dsn_string = "https://key@192.168.1.1/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("192.168.1.1", dsn.host) + end) + + it("should handle very long project IDs", function() + local long_id = "123456789012345678901234567890" + local dsn_string = "https://key@host.com/" .. long_id + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal(long_id, dsn.project_id) + end) + end) +end) \ No newline at end of file diff --git a/spec/init.lua b/spec/init.lua new file mode 100644 index 0000000..12dbb63 --- /dev/null +++ b/spec/init.lua @@ -0,0 +1,18 @@ +-- Auto-setup when spec/ is required as a module +local info = debug.getinfo(2, "S") -- Use level 2 to get caller's info +if info and info.source and info.source:sub(1,1) == "@" then + local current_file = info.source:sub(2) + local current_dir = current_file:match("(.*/)") + if current_dir then + -- Find project root by looking for spec dir or assume current + local project_root = current_dir:match("(.*/spec/)") or current_dir:match("(.*/src/)") or current_dir + if project_root then + project_root = project_root:gsub("spec/$", ""):gsub("src/$", "") + local sentry_src = project_root .. "src/sentry/?.lua" + local sentry_init = project_root .. "src/sentry/?/init.lua" + if not package.path:find(sentry_src, 1, true) then + package.path = sentry_src .. ";" .. sentry_init .. ";" .. package.path + end + end + end +end \ No newline at end of file diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua new file mode 100644 index 0000000..09f2ba0 --- /dev/null +++ b/spec/spec_helper.lua @@ -0,0 +1,27 @@ +-- Auto-setup when required - no function call needed +local info = debug.getinfo(2, "S") -- Use level 2 to get caller's info +if info and info.source and info.source:sub(1,1) == "@" then + local current_file = info.source:sub(2) + local current_dir = current_file:match("(.*/)") + if current_dir then + -- Find project root by looking for spec dir or assume current + local project_root = current_dir:match("(.*/spec/)") + if project_root then + project_root = project_root:sub(1, -6) -- Remove "spec/" + else + project_root = current_dir + end + + -- Add spec and src paths + local spec_path = project_root .. "spec/?.lua" + local sentry_src = project_root .. "src/sentry/?.lua" + local sentry_init = project_root .. "src/sentry/?/init.lua" + + if not package.path:find(spec_path, 1, true) then + package.path = spec_path .. ";" .. package.path + end + if not package.path:find(sentry_src, 1, true) then + package.path = sentry_src .. ";" .. sentry_init .. ";" .. package.path + end + end +end \ No newline at end of file diff --git a/spec/test_template.lua b/spec/test_template.lua new file mode 100644 index 0000000..4d81109 --- /dev/null +++ b/spec/test_template.lua @@ -0,0 +1,21 @@ +-- Copy this template for new tests +-- 1. Copy this file to your test name (e.g., transport_spec.lua) +-- 2. Change the require and module names +-- 3. Add your test cases + +require("spec") + +describe("Your Module Name", function() + local your_module + + before_each(function() + your_module = require("core.your_module") + end) + + describe("Feature Group", function() + it("should do something", function() + -- Your test here + assert.are.equal(expected, actual) + end) + end) +end) \ No newline at end of file diff --git a/src/sentry/core/dsn.lua b/src/sentry/core/dsn.lua new file mode 100644 index 0000000..50f0dc1 --- /dev/null +++ b/src/sentry/core/dsn.lua @@ -0,0 +1,90 @@ +local version = require("core.version") + +local function parse_dsn(dsn_string) + if not dsn_string or dsn_string == "" then + return {}, "DSN is required" + end + + -- Pattern to match DSN with optional secret key + -- https://public_key:secret_key@host:port/path/project_id + -- https://public_key@host:port/path/project_id + local protocol, credentials, host_path = dsn_string:match("^(https?)://([^@]+)@(.+)$") + + if not protocol or not credentials or not host_path then + return {}, "Invalid DSN format" + end + + -- Parse credentials (public_key or public_key:secret_key) + local public_key, secret_key = credentials:match("^([^:]+):(.+)$") + if not public_key then + public_key = credentials + secret_key = "" + end + + if not public_key or public_key == "" then + return {}, "Invalid DSN format" + end + + -- Parse host and path + local host, path = host_path:match("^([^/]+)(.*)$") + if not host or not path or path == "" then + return {}, "Invalid DSN format" + end + + -- Extract project ID from path (last numeric segment) + local project_id = path:match("/([%d]+)$") + if not project_id then + return {}, "Could not extract project ID from DSN" + end + + -- Parse port from host + local port = 443 + if protocol == "http" then + port = 80 + end + + local host_part, port_part = host:match("^([^:]+):?(%d*)$") + if host_part then + host = host_part + if port_part and port_part ~= "" then + port = tonumber(port_part) or port + end + end + + return { + protocol = protocol, + public_key = public_key, + secret_key = secret_key or "", + host = host, + port = port, + path = path, + project_id = project_id + }, nil +end + +local function build_envelope_url(dsn) + return string.format("%s://%s/api/%s/envelope/", + dsn.protocol, + dsn.host, + dsn.project_id) +end + +local function build_auth_header(dsn) + local auth_parts = { + "Sentry sentry_version=7", + "sentry_key=" .. dsn.public_key, + "sentry_client=sentry-lua/" .. version + } + + if dsn.secret_key and dsn.secret_key ~= "" then + table.insert(auth_parts, "sentry_secret=" .. dsn.secret_key) + end + + return table.concat(auth_parts, ", ") +end + +return { + parse_dsn = parse_dsn, + build_envelope_url = build_envelope_url, + build_auth_header = build_auth_header +} \ No newline at end of file From ca2e3a3fffe0f79c538c1083c60a2c7272b10514 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 09:57:48 -0400 Subject: [PATCH 08/23] message takes level --- src/sentry/init.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sentry/init.lua b/src/sentry/init.lua index 6ef5b62..956c6a7 100644 --- a/src/sentry/init.lua +++ b/src/sentry/init.lua @@ -24,7 +24,7 @@ local function init(options) sentry._client = Client:new(options) sentry._scope = Scope:new() end -local function capture_message(message) +local function capture_message(message, level) if not sentry._client then logger.debug("Sentry SDK has not been initialized") return From 979aed052deb9f47bd1a7c32b8d7922d24b95b94 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 10:39:20 -0400 Subject: [PATCH 09/23] can't run tests in vscode --- .busted | 5 ++--- .vscode/launch.json | 14 ++++++++++++++ spec/dsn_parsing_spec.lua | 5 ++--- spec/init.lua | 18 ------------------ spec/spec_helper.lua | 27 --------------------------- spec/test_template.lua | 21 --------------------- 6 files changed, 18 insertions(+), 72 deletions(-) create mode 100644 .vscode/launch.json delete mode 100644 spec/init.lua delete mode 100644 spec/spec_helper.lua delete mode 100644 spec/test_template.lua diff --git a/.busted b/.busted index b8be4c6..d39ce14 100644 --- a/.busted +++ b/.busted @@ -1,7 +1,6 @@ return { default = { - pattern = "_spec", ROOT = {"spec/"}, - lpath = "src/?.lua;src/?/init.lua;src/sentry/?.lua;src/sentry/?/init.lua;build/?.lua;build/?/init.lua" - } + lpath = "src/?.lua;src/?/init.lua;src/sentry/?.lua;src/sentry/?/init.lua" + }, } \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..7215bab --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,14 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "type": "lua", + "request": "launch", + "name": "Debug", + "program": "${workspaceFolder}/examples/basic.lua", + }, + ] +} \ No newline at end of file diff --git a/spec/dsn_parsing_spec.lua b/spec/dsn_parsing_spec.lua index 695d702..4cb67e9 100644 --- a/spec/dsn_parsing_spec.lua +++ b/spec/dsn_parsing_spec.lua @@ -1,10 +1,8 @@ -require("spec") - describe("DSN Parsing", function() local dsn_utils before_each(function() - dsn_utils = require("core.dsn") + dsn_utils = require("sentry.core.dsn") end) describe("Valid DSN parsing", function() @@ -86,6 +84,7 @@ describe("DSN Parsing", function() it("should reject nil DSN", function() local dsn, error = dsn_utils.parse_dsn(nil) + -- assert.is_nil(error) assert.is_not_nil(error) assert.are.equal("DSN is required", error) end) diff --git a/spec/init.lua b/spec/init.lua deleted file mode 100644 index 12dbb63..0000000 --- a/spec/init.lua +++ /dev/null @@ -1,18 +0,0 @@ --- Auto-setup when spec/ is required as a module -local info = debug.getinfo(2, "S") -- Use level 2 to get caller's info -if info and info.source and info.source:sub(1,1) == "@" then - local current_file = info.source:sub(2) - local current_dir = current_file:match("(.*/)") - if current_dir then - -- Find project root by looking for spec dir or assume current - local project_root = current_dir:match("(.*/spec/)") or current_dir:match("(.*/src/)") or current_dir - if project_root then - project_root = project_root:gsub("spec/$", ""):gsub("src/$", "") - local sentry_src = project_root .. "src/sentry/?.lua" - local sentry_init = project_root .. "src/sentry/?/init.lua" - if not package.path:find(sentry_src, 1, true) then - package.path = sentry_src .. ";" .. sentry_init .. ";" .. package.path - end - end - end -end \ No newline at end of file diff --git a/spec/spec_helper.lua b/spec/spec_helper.lua deleted file mode 100644 index 09f2ba0..0000000 --- a/spec/spec_helper.lua +++ /dev/null @@ -1,27 +0,0 @@ --- Auto-setup when required - no function call needed -local info = debug.getinfo(2, "S") -- Use level 2 to get caller's info -if info and info.source and info.source:sub(1,1) == "@" then - local current_file = info.source:sub(2) - local current_dir = current_file:match("(.*/)") - if current_dir then - -- Find project root by looking for spec dir or assume current - local project_root = current_dir:match("(.*/spec/)") - if project_root then - project_root = project_root:sub(1, -6) -- Remove "spec/" - else - project_root = current_dir - end - - -- Add spec and src paths - local spec_path = project_root .. "spec/?.lua" - local sentry_src = project_root .. "src/sentry/?.lua" - local sentry_init = project_root .. "src/sentry/?/init.lua" - - if not package.path:find(spec_path, 1, true) then - package.path = spec_path .. ";" .. package.path - end - if not package.path:find(sentry_src, 1, true) then - package.path = sentry_src .. ";" .. sentry_init .. ";" .. package.path - end - end -end \ No newline at end of file diff --git a/spec/test_template.lua b/spec/test_template.lua deleted file mode 100644 index 4d81109..0000000 --- a/spec/test_template.lua +++ /dev/null @@ -1,21 +0,0 @@ --- Copy this template for new tests --- 1. Copy this file to your test name (e.g., transport_spec.lua) --- 2. Change the require and module names --- 3. Add your test cases - -require("spec") - -describe("Your Module Name", function() - local your_module - - before_each(function() - your_module = require("core.your_module") - end) - - describe("Feature Group", function() - it("should do something", function() - -- Your test here - assert.are.equal(expected, actual) - end) - end) -end) \ No newline at end of file From d84225a0d6e69f078af0ccefabb4e6ad84f0c601 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 10:50:43 -0400 Subject: [PATCH 10/23] linter + fix spec lints --- .luacheckrc | 32 +++++++++++++ examples/basic.lua | 35 +++++++-------- examples/wrap_demo.lua | 24 +++++----- sentry-0.0.7-1.rockspec | 3 ++ spec/dsn_parsing_spec.lua | 94 +++++++++++++++++++++------------------ src/sentry/core/dsn.lua | 24 +++++----- src/sentry/core/scope.lua | 2 +- 7 files changed, 128 insertions(+), 86 deletions(-) create mode 100644 .luacheckrc diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000..8436367 --- /dev/null +++ b/.luacheckrc @@ -0,0 +1,32 @@ +-- Luacheck configuration file + +-- Standard library compatibility +std = "lua51+lua52+lua53+lua54" + +-- Files and directories to exclude from checking +exclude_files = { + ".luarocks/", + ".git/", +} + +-- Per-directory configuration +files["spec/"] = { + -- Allow using busted globals in test files + globals = { + "describe", "it", "before_each", "after_each", "setup", "teardown", + "assert", "spy", "stub", "mock", "pending", "finally", + "insulate", "expose", + } +} + +-- Ignore certain warnings +ignore = { + "212", -- unused argument (common in callbacks) + "213", -- unused loop variable (common in iterations) +} + +-- Maximum line length +max_line_length = 120 + +-- Maximum cyclomatic complexity +max_cyclomatic_complexity = 12 \ No newline at end of file diff --git a/examples/basic.lua b/examples/basic.lua index b6e46b1..bf6495a 100644 --- a/examples/basic.lua +++ b/examples/basic.lua @@ -62,10 +62,10 @@ checkout_handler("user_12345", "sess_abcdef123456") local function array_access_error(department, min_users_required) local users = {"alice", "bob", "charlie"} local debug_info = "Checking " .. department .. " (need " .. min_users_required .. " users)" - + -- This will access nil (common mistake from other languages) local first_user = users[0] -- Should be users[1] - + -- This will cause an error when trying to use nil return string.upper(first_user) .. " in " .. debug_info end @@ -74,7 +74,7 @@ end local function string_concat_error(greeting_type, username, user_id) local timestamp = os.time() local session_id = "sess_" .. math.random(1000, 9999) - + -- Wrong: using + instead of .. for concatenation local message = greeting_type + " " + username + " (ID: " + user_id + ") at " + timestamp return message .. " session: " .. session_id @@ -84,8 +84,7 @@ end local function nil_method_call(service_name, environment) local config = nil -- Simulate missing configuration local default_timeout = 30 - local retry_count = 3 - + -- This will error: attempt to index a nil value local timeout = config.timeout or default_timeout return "Service " .. service_name .. " (" .. environment .. ") timeout: " .. timeout @@ -96,13 +95,13 @@ local function table_iteration_error(record_type, max_items) local data = {name = "test_record", value = 42, priority = "high"} local result = "" local processed_count = 0 - + -- Wrong: using ipairs on a hash table (should use pairs) for i, v in ipairs(data) do result = result .. tostring(v) processed_count = processed_count + 1 end - + return record_type .. ": processed " .. processed_count .. "/" .. max_items .. " -> " .. result end @@ -117,11 +116,11 @@ sentry.add_breadcrumb({ sentry.with_scope(function(scope) scope:set_tag("error_type", "array_indexing") scope:set_extra("expected_behavior", "Lua arrays are 1-indexed, not 0-indexed") - + local function safe_array_access() array_access_error("engineering", 2) end - + xpcall(safe_array_access, function(err) sentry.capture_exception({ type = "IndexError", @@ -135,11 +134,11 @@ end) sentry.with_scope(function(scope) scope:set_tag("error_type", "string_concatenation") scope:set_extra("expected_behavior", "Lua uses .. for string concatenation, not +") - + local function safe_string_concat() string_concat_error("Hello", "john_doe", 42) end - + xpcall(safe_string_concat, function(err) sentry.capture_exception({ type = "TypeError", @@ -153,11 +152,11 @@ end) sentry.with_scope(function(scope) scope:set_tag("error_type", "nil_access") scope:set_extra("expected_behavior", "Always check for nil before accessing table fields") - + local function safe_nil_access() nil_method_call("database", "production") end - + xpcall(safe_nil_access, function(err) sentry.capture_exception({ type = "NilAccessError", @@ -171,7 +170,7 @@ end) sentry.with_scope(function(scope) scope:set_tag("error_type", "iteration_logic") scope:set_extra("expected_behavior", "Use pairs() for hash tables, ipairs() for arrays") - + local result = table_iteration_error("user_profile", 10) if result:find("processed 0/") then sentry.capture_message("Table iteration produced empty result - likely using wrong iterator", "warning") @@ -219,13 +218,13 @@ sentry.add_breadcrumb({ print("\n=== Manual Error Handling ===") sentry.with_scope(function(scope) scope:set_tag("handling_method", "manual") - + local function manual_error_demo(operation_type, resource_id) local data = nil local context = "Processing " .. operation_type .. " for resource " .. resource_id return data.missing_field .. " (" .. context .. ")" -- Will cause nil access error end - + xpcall(function() manual_error_demo("update", "res_456") end, function(err) sentry.capture_exception({ type = "ManuallyHandledError", @@ -243,11 +242,11 @@ print("The following error will be automatically captured by Sentry:") sentry.with_scope(function(scope) scope:set_tag("handling_method", "automatic") scope:set_extra("note", "This error is automatically captured without xpcall") - + -- Uncomment the next line to test automatic capture -- WARNING: This will terminate the program! -- error("This error is automatically captured!") - + print("[Automatic] Error capture is enabled - any unhandled error() calls are automatically sent to Sentry") end) diff --git a/examples/wrap_demo.lua b/examples/wrap_demo.lua index 4e2c06f..676e4d1 100644 --- a/examples/wrap_demo.lua +++ b/examples/wrap_demo.lua @@ -25,7 +25,7 @@ local function process_database_config(environment, service_name, retry_count) local config = nil -- Simulate missing configuration local timeout_ms = 5000 local connection_pool_size = 10 - + -- This will cause an error that gets automatically captured local db_url = config.database_url -- Error: attempt to index nil return db_url .. "?timeout=" .. timeout_ms .. "&pool=" .. connection_pool_size @@ -34,7 +34,7 @@ end local function validate_user_permissions(user_id, action_type, resource_id) local permissions = {"read", "write", "admin"} local session_data = {expires_at = os.time() + 3600, csrf_token = "abc123"} - + -- Process permissions validation return process_database_config("production", "user_service", 3) end @@ -42,24 +42,24 @@ end local function main(app_version, startup_mode) print("=== Sentry.wrap() Demo === (v" .. app_version .. ", mode: " .. startup_mode .. ")") local instance_id = "inst_" .. math.random(1000, 9999) - + -- Add some context sentry.add_breadcrumb({ message = "Application started", category = "lifecycle", level = "info" }) - + print("1. Setting up user data...") local users = {"alice", "bob", "charlie"} - + print("2. Processing payments...") sentry.add_breadcrumb({ message = "Starting payment processing", category = "business", level = "info" }) - + -- Simulate some successful operations for i, user in ipairs(users) do local amount = 100 * i @@ -71,11 +71,11 @@ local function main(app_version, startup_mode) data = { user = user, amount = amount } }) end - + print("3. Validating user permissions...") -- This will ultimately cause an error through the call chain local result = validate_user_permissions("user_12345", "database_write", "config_table") - + print("This line should never be reached:", result) end @@ -96,9 +96,9 @@ print("\n=== Method 2: Custom Error Handler ===") local function attempt_risky_operation(operation_id, max_retries, timeout_seconds) local cache_key = "op_" .. operation_id local start_time = os.time() - + print("Attempting risky operation (ID: " .. operation_id .. ", retries: " .. max_retries .. ")...") - + local risky_data = nil return risky_data.missing_field .. " cached as " .. cache_key -- This will error end @@ -110,7 +110,7 @@ end local function custom_error_handler(err) local error_id = "err_" .. math.random(10000, 99999) local timestamp = os.date("%Y-%m-%d %H:%M:%S") - + print("Custom handler: Caught error [" .. error_id .. " at " .. timestamp .. "]:", err) print("Custom handler: Performing cleanup...") -- You could do cleanup, logging, etc. here @@ -133,7 +133,7 @@ print("\n=== Comparison with Manual Approach ===") local function manual_error_simulation(task_name, priority_level) local task_id = "task_" .. math.random(100, 999) local worker_id = "worker_A1" - + error("Manual error handling for " .. task_name .. " (priority: " .. priority_level .. ", task: " .. task_id .. ")") end diff --git a/sentry-0.0.7-1.rockspec b/sentry-0.0.7-1.rockspec index 202877c..45cfaed 100644 --- a/sentry-0.0.7-1.rockspec +++ b/sentry-0.0.7-1.rockspec @@ -18,6 +18,9 @@ dependencies = { "luasocket >= 3.0", "luasec >= 1.0", } +test_dependencies = { + "luacheck >= 0.23.0", +} build = { type = "builtin", modules = { diff --git a/spec/dsn_parsing_spec.lua b/spec/dsn_parsing_spec.lua index 4cb67e9..a9e97ce 100644 --- a/spec/dsn_parsing_spec.lua +++ b/spec/dsn_parsing_spec.lua @@ -46,98 +46,105 @@ describe("DSN Parsing", function() it("should parse DSN with custom port", function() local dsn_string = "https://public_key@sentry.example.com:9000/123" local dsn, error = dsn_utils.parse_dsn(dsn_string) - + assert.is_nil(error or (error ~= "" and error or nil)) assert.are.equal("sentry.example.com", dsn.host) assert.are.equal(9000, dsn.port) end) - + it("should parse DSN with path prefix", function() local dsn_string = "https://public_key@sentry.io/path/to/123" local dsn, error = dsn_utils.parse_dsn(dsn_string) - + assert.is_nil(error or (error ~= "" and error or nil)) assert.are.equal("/path/to/123", dsn.path) assert.are.equal("123", dsn.project_id) end) - + it("should parse DSN with subdomain", function() local dsn_string = "https://abc123@org.ingest.sentry.io/456789" local dsn, error = dsn_utils.parse_dsn(dsn_string) - + assert.is_nil(error or (error ~= "" and error or nil)) assert.are.equal("abc123", dsn.public_key) assert.are.equal("org.ingest.sentry.io", dsn.host) assert.are.equal("456789", dsn.project_id) end) - + it("should handle numeric project IDs correctly", function() local dsn_string = "https://key@host.com/1234567890" local dsn, error = dsn_utils.parse_dsn(dsn_string) - + assert.is_nil(error or (error ~= "" and error or nil)) assert.are.equal("1234567890", dsn.project_id) end) end) - + describe("Invalid DSN parsing", function() it("should reject nil DSN", function() local dsn, error = dsn_utils.parse_dsn(nil) - - -- assert.is_nil(error) + + assert.is_nil(dsn) assert.is_not_nil(error) assert.are.equal("DSN is required", error) end) - + it("should reject empty string DSN", function() local dsn, error = dsn_utils.parse_dsn("") - + + assert.is_nil(dsn) assert.is_not_nil(error) assert.are.equal("DSN is required", error) end) - + it("should reject DSN without protocol", function() local dsn, error = dsn_utils.parse_dsn("public_key@sentry.io/123") - + + assert.is_nil(dsn) assert.is_not_nil(error) assert.are.equal("Invalid DSN format", error) end) - + it("should reject DSN without public key", function() local dsn, error = dsn_utils.parse_dsn("https://@sentry.io/123") - + + assert.is_nil(dsn) assert.is_not_nil(error) assert.are.equal("Invalid DSN format", error) end) - + it("should reject DSN without host", function() local dsn, error = dsn_utils.parse_dsn("https://public_key@/123") - + + assert.is_nil(dsn) assert.is_not_nil(error) assert.are.equal("Invalid DSN format", error) end) - + it("should reject DSN without path", function() local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io") - + + assert.is_nil(dsn) assert.is_not_nil(error) assert.are.equal("Invalid DSN format", error) end) - + it("should reject DSN without project ID", function() local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/") - + + assert.is_nil(dsn) assert.is_not_nil(error) assert.are.equal("Could not extract project ID from DSN", error) end) - + it("should reject DSN with non-numeric project ID", function() local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/project") - + + assert.is_nil(dsn) assert.is_not_nil(error) assert.are.equal("Could not extract project ID from DSN", error) end) - + it("should reject malformed URLs", function() local malformed_dsns = { "not-a-url", @@ -146,88 +153,89 @@ describe("DSN Parsing", function() "https:///path/only", "ftp://wrong-scheme@host.com/123", } - + for _, dsn_string in ipairs(malformed_dsns) do local dsn, error = dsn_utils.parse_dsn(dsn_string) + assert.is_nil(dsn) assert.is_not_nil(error, "Expected error for DSN: " .. dsn_string) end end) - + it("should handle invalid port gracefully", function() -- This tests the port parsing - invalid ports should fallback to default local dsn_string = "https://key@host.com:invalid/123" local dsn, error = dsn_utils.parse_dsn(dsn_string) - + assert.is_nil(error or (error ~= "" and error or nil)) assert.are.equal(443, dsn.port) -- Should fallback to default HTTPS port end) end) - + describe("Auth header building", function() it("should build auth header with secret key", function() local dsn = { public_key = "public123", secret_key = "secret456" } - + local header = dsn_utils.build_auth_header(dsn) - + -- Check that all required parts are present (version-agnostic) assert.is_true(header:find("Sentry sentry_version=7") ~= nil) assert.is_true(header:find("sentry_key=public123") ~= nil) assert.is_true(header:find("sentry_secret=secret456") ~= nil) assert.is_true(header:find("sentry_client=sentry%-lua/") ~= nil) -- Version-agnostic check end) - + it("should build auth header without secret key", function() local dsn = { public_key = "public123", secret_key = "" } - + local header = dsn_utils.build_auth_header(dsn) - + -- Should not include secret assert.is_true(header:find("sentry_key=public123") ~= nil) assert.is_true(header:find("sentry_secret") == nil) end) - + it("should build auth header with nil secret key", function() local dsn = { public_key = "public123", secret_key = nil } - + local header = dsn_utils.build_auth_header(dsn) - + -- Should not include secret assert.is_true(header:find("sentry_key=public123") ~= nil) assert.is_true(header:find("sentry_secret") == nil) end) end) - + describe("Edge cases and security", function() it("should handle special characters in keys", function() local dsn_string = "https://key-with.dots_and-dashes@host.com/123" local dsn, error = dsn_utils.parse_dsn(dsn_string) - + assert.is_nil(error or (error ~= "" and error or nil)) assert.are.equal("key-with.dots_and-dashes", dsn.public_key) end) - + it("should handle IPv4 addresses", function() local dsn_string = "https://key@192.168.1.1/123" local dsn, error = dsn_utils.parse_dsn(dsn_string) - + assert.is_nil(error or (error ~= "" and error or nil)) assert.are.equal("192.168.1.1", dsn.host) end) - + it("should handle very long project IDs", function() local long_id = "123456789012345678901234567890" local dsn_string = "https://key@host.com/" .. long_id local dsn, error = dsn_utils.parse_dsn(dsn_string) - + assert.is_nil(error or (error ~= "" and error or nil)) assert.are.equal(long_id, dsn.project_id) end) diff --git a/src/sentry/core/dsn.lua b/src/sentry/core/dsn.lua index 50f0dc1..34e768c 100644 --- a/src/sentry/core/dsn.lua +++ b/src/sentry/core/dsn.lua @@ -2,47 +2,47 @@ local version = require("core.version") local function parse_dsn(dsn_string) if not dsn_string or dsn_string == "" then - return {}, "DSN is required" + return nil, "DSN is required" end - + -- Pattern to match DSN with optional secret key -- https://public_key:secret_key@host:port/path/project_id -- https://public_key@host:port/path/project_id local protocol, credentials, host_path = dsn_string:match("^(https?)://([^@]+)@(.+)$") - + if not protocol or not credentials or not host_path then return {}, "Invalid DSN format" end - + -- Parse credentials (public_key or public_key:secret_key) local public_key, secret_key = credentials:match("^([^:]+):(.+)$") if not public_key then public_key = credentials secret_key = "" end - + if not public_key or public_key == "" then return {}, "Invalid DSN format" end - + -- Parse host and path local host, path = host_path:match("^([^/]+)(.*)$") if not host or not path or path == "" then return {}, "Invalid DSN format" end - + -- Extract project ID from path (last numeric segment) local project_id = path:match("/([%d]+)$") if not project_id then return {}, "Could not extract project ID from DSN" end - + -- Parse port from host local port = 443 if protocol == "http" then port = 80 end - + local host_part, port_part = host:match("^([^:]+):?(%d*)$") if host_part then host = host_part @@ -50,7 +50,7 @@ local function parse_dsn(dsn_string) port = tonumber(port_part) or port end end - + return { protocol = protocol, public_key = public_key, @@ -75,11 +75,11 @@ local function build_auth_header(dsn) "sentry_key=" .. dsn.public_key, "sentry_client=sentry-lua/" .. version } - + if dsn.secret_key and dsn.secret_key ~= "" then table.insert(auth_parts, "sentry_secret=" .. dsn.secret_key) end - + return table.concat(auth_parts, ", ") end diff --git a/src/sentry/core/scope.lua b/src/sentry/core/scope.lua index 854d7b4..a194e11 100644 --- a/src/sentry/core/scope.lua +++ b/src/sentry/core/scope.lua @@ -33,7 +33,7 @@ function Scope:add_breadcrumb(breadcrumb) data = breadcrumb.data -- or {} } table.insert(self.breadcrumbs, crumb) - + while #self.breadcrumbs > self.max_breadcrumbs do table.remove(self.breadcrumbs, 1) end From 1b37062a5264cc27870e9bd9bba31c2f6aa4745a Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 11:06:27 -0400 Subject: [PATCH 11/23] fix linter issues --- .luacheckrc | 24 +++-- examples/love2d/conf.lua | 6 +- examples/love2d/main.lua | 120 ++++++++++++------------- examples/roblox/sentry-all-in-one.luau | 88 +++++++++--------- src/sentry/core/client.lua | 4 +- 5 files changed, 124 insertions(+), 118 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 8436367..a92fc28 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1,15 +1,13 @@ -- Luacheck configuration file -- Standard library compatibility -std = "lua51+lua52+lua53+lua54" +std = "lua51+lua52+lua53+lua54+luajit" --- Files and directories to exclude from checking exclude_files = { ".luarocks/", ".git/", } --- Per-directory configuration files["spec/"] = { -- Allow using busted globals in test files globals = { @@ -19,14 +17,22 @@ files["spec/"] = { } } --- Ignore certain warnings +files["**/love2d*"] = { + globals = { + "love", + } +} + +files["**/roblox*"] = { + globals = { + "game", "DateTime", "task", "Instance", "getgenv", "shared", "Enum" + } +} + ignore = { "212", -- unused argument (common in callbacks) "213", -- unused loop variable (common in iterations) } --- Maximum line length -max_line_length = 120 - --- Maximum cyclomatic complexity -max_cyclomatic_complexity = 12 \ No newline at end of file +max_line_length = 160 +max_cyclomatic_complexity = 20 \ No newline at end of file diff --git a/examples/love2d/conf.lua b/examples/love2d/conf.lua index aaad323..e2f31bf 100644 --- a/examples/love2d/conf.lua +++ b/examples/love2d/conf.lua @@ -8,10 +8,10 @@ function love.conf(t) t.accelerometerjoystick = false -- Enable accelerometer on mobile devices t.externalstorage = false -- True to save files outside of the app folder t.gammacorrect = false -- Enable gamma-correct rendering - + t.audio.mic = false -- Request microphone permission t.audio.mixwithsystem = true -- Keep background music playing - + t.window.title = "Love2D Sentry Integration Demo" t.window.icon = nil -- Icon file path t.window.width = 800 -- Window width @@ -31,7 +31,7 @@ function love.conf(t) t.window.usedpiscale = true -- Enable automatic DPI scaling t.window.x = nil -- Window position x-coordinate t.window.y = nil -- Window position y-coordinate - + -- Disable unused modules for faster startup and smaller memory footprint t.modules.audio = true -- Enable audio module t.modules.data = false -- Enable data module diff --git a/examples/love2d/main.lua b/examples/love2d/main.lua index d681b0f..5e1dcae 100644 --- a/examples/love2d/main.lua +++ b/examples/love2d/main.lua @@ -12,10 +12,10 @@ local game = { font_large = nil, font_small = nil, button_font = nil, - + -- Sentry logo data (simple representation) logo_points = {}, - + -- Button state button = { x = 250, @@ -26,7 +26,7 @@ local game = { hover = false, pressed = false }, - + -- Fatal error button fatal_button = { x = 430, @@ -37,11 +37,11 @@ local game = { hover = false, pressed = false }, - + -- Error state error_count = 0, last_error_time = 0, - + -- Demo functions for stack trace demo_functions = {} } @@ -50,7 +50,7 @@ function love.load() -- Initialize window love.window.setTitle("Love2D Sentry Integration Demo") love.window.setMode(800, 600, {resizable = false}) - + -- Initialize Sentry sentry.init({ dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", @@ -58,7 +58,7 @@ function love.load() release = "love2d-example@1.0.0", debug = true }) - + -- Debug: Check which transport is being used if sentry._client and sentry._client.transport then print("[Debug] Transport type:", type(sentry._client.transport)) @@ -66,7 +66,7 @@ function love.load() else print("[Debug] No transport found!") end - + -- Initialize logger logger.init({ enable_logs = true, @@ -74,23 +74,23 @@ function love.load() flush_timeout = 2.0, hook_print = true }) - + -- Set user context sentry.set_user({ id = "love2d_user_" .. math.random(1000, 9999), username = "love2d_demo_user" }) - + -- Add tags for filtering sentry.set_tag("framework", "love2d") sentry.set_tag("version", table.concat({love.getVersion()}, ".")) sentry.set_tag("platform", love.system.getOS()) - + -- Load fonts game.font_large = love.graphics.newFont(32) game.font_small = love.graphics.newFont(16) game.button_font = love.graphics.newFont(18) - + -- Create Sentry logo points (simple S shape) game.logo_points = { -- Top part of S @@ -99,28 +99,28 @@ function love.load() -- Bottom part of S {150, 170}, {120, 170}, {120, 200}, {180, 200} } - + -- Initialize demo functions for multi-frame stack traces game.demo_functions = { level1 = function(user_action, error_type) logger.info("Level 1: Processing user action %s", {user_action}) return game.demo_functions.level2(error_type, "processing") end, - + level2 = function(action_type, status) logger.debug("Level 2: Executing %s with status %s", {action_type, status}) return game.demo_functions.level3(action_type) end, - + level3 = function(error_category) logger.warn("Level 3: About to trigger %s error", {error_category}) return game.demo_functions.trigger_error(error_category) end, - + trigger_error = function(category) game.error_count = game.error_count + 1 game.last_error_time = love.timer.getTime() - + -- Create realistic error scenarios if category == "button_click" then logger.error("Critical error in button handler") @@ -134,12 +134,12 @@ function love.load() end end } - + -- Log successful initialization logger.info("Love2D Sentry demo initialized successfully") logger.info("Love2D version: %s", {table.concat({love.getVersion()}, ".")}) logger.info("Operating system: %s", {love.system.getOS()}) - + -- Add breadcrumb for debugging context sentry.add_breadcrumb({ message = "Love2D game initialized", @@ -152,22 +152,22 @@ function love.update(dt) -- Get mouse position for button hover detection local mouse_x, mouse_y = love.mouse.getPosition() local button = game.button - + -- Check if mouse is over button local was_hover = button.hover button.hover = (mouse_x >= button.x and mouse_x <= button.x + button.width and mouse_y >= button.y and mouse_y <= button.y + button.height) - + -- Log hover state changes if button.hover and not was_hover then logger.debug("Button hover state: entered") elseif not button.hover and was_hover then logger.debug("Button hover state: exited") end - + -- Flush Sentry transport periodically sentry.flush() - + -- Flush logger periodically if math.floor(love.timer.getTime()) % 3 == 0 then logger.flush() @@ -177,16 +177,16 @@ end function love.draw() -- Clear screen with dark background love.graphics.clear(0.1, 0.1, 0.15, 1.0) - + -- Draw title love.graphics.setFont(game.font_large) love.graphics.setColor(1, 1, 1, 1) love.graphics.printf("Love2D Sentry Integration", 0, 50, love.graphics.getWidth(), "center") - + -- Draw Sentry logo (simple S shape) love.graphics.setColor(0.4, 0.3, 0.8, 1) -- Purple color similar to Sentry love.graphics.setLineWidth(8) - + -- Draw S shape local logo_x, logo_y = 350, 120 for i = 1, #game.logo_points - 1 do @@ -194,29 +194,29 @@ function love.draw() local x2, y2 = game.logo_points[i + 1][1] + logo_x, game.logo_points[i + 1][2] + logo_y love.graphics.line(x1, y1, x2, y2) end - + -- Draw info text love.graphics.setFont(game.font_small) love.graphics.setColor(0.8, 0.8, 0.8, 1) love.graphics.printf("This demo shows Love2D integration with Sentry SDK", 0, 280, love.graphics.getWidth(), "center") love.graphics.printf("Red: Regular error (caught) • Purple: Fatal error (love.errorhandler)", 0, 300, love.graphics.getWidth(), "center") love.graphics.printf("Press 'R' for regular error, 'F' for fatal error, 'ESC' to exit", 0, 320, love.graphics.getWidth(), "center") - + -- Draw button local button = game.button local button_color = button.hover and {0.8, 0.2, 0.2, 1} or {0.6, 0.1, 0.1, 1} if button.pressed then button_color = {1.0, 0.3, 0.3, 1} end - + love.graphics.setColor(button_color[1], button_color[2], button_color[3], button_color[4]) love.graphics.rectangle("fill", button.x, button.y, button.width, button.height, 8, 8) - + -- Draw button border love.graphics.setColor(1, 1, 1, 0.3) love.graphics.setLineWidth(2) love.graphics.rectangle("line", button.x, button.y, button.width, button.height, 8, 8) - + -- Draw button text love.graphics.setFont(game.button_font) love.graphics.setColor(1, 1, 1, 1) @@ -225,22 +225,22 @@ function love.draw() love.graphics.print(button.text, button.x + (button.width - text_width) / 2, button.y + (button.height - text_height) / 2) - + -- Draw fatal button local fatal_button = game.fatal_button local fatal_button_color = fatal_button.hover and {0.8, 0.2, 0.8, 1} or {0.6, 0.1, 0.6, 1} if fatal_button.pressed then fatal_button_color = {1.0, 0.3, 1.0, 1} end - + love.graphics.setColor(fatal_button_color[1], fatal_button_color[2], fatal_button_color[3], fatal_button_color[4]) love.graphics.rectangle("fill", fatal_button.x, fatal_button.y, fatal_button.width, fatal_button.height, 8, 8) - + -- Draw fatal button border love.graphics.setColor(1, 1, 1, 0.3) love.graphics.setLineWidth(2) love.graphics.rectangle("line", fatal_button.x, fatal_button.y, fatal_button.width, fatal_button.height, 8, 8) - + -- Draw fatal button text love.graphics.setFont(game.button_font) love.graphics.setColor(1, 1, 1, 1) @@ -248,14 +248,14 @@ function love.draw() love.graphics.print(fatal_button.text, fatal_button.x + (fatal_button.width - fatal_text_width) / 2, fatal_button.y + (fatal_button.height - text_height) / 2) - + -- Draw stats love.graphics.setFont(game.font_small) love.graphics.setColor(0.7, 0.7, 0.7, 1) love.graphics.print(string.format("Errors triggered: %d", game.error_count), 20, love.graphics.getHeight() - 60) love.graphics.print(string.format("Framework: Love2D %s", table.concat({love.getVersion()}, ".")), 20, love.graphics.getHeight() - 40) love.graphics.print(string.format("Platform: %s", love.system.getOS()), 20, love.graphics.getHeight() - 20) - + if game.last_error_time > 0 then love.graphics.print(string.format("Last error: %.1fs ago", love.timer.getTime() - game.last_error_time), 400, love.graphics.getHeight() - 40) end @@ -264,13 +264,13 @@ end function love.mousepressed(x, y, button_num, istouch, presses) if button_num == 1 then -- Left mouse button local button = game.button - + -- Check if click is within button bounds if x >= button.x and x <= button.x + button.width and y >= button.y and y <= button.y + button.height then - + button.pressed = true - + -- Add breadcrumb before triggering error sentry.add_breadcrumb({ message = "Error button clicked", @@ -282,35 +282,35 @@ function love.mousepressed(x, y, button_num, istouch, presses) error_count = game.error_count + 1 } }) - + -- Log the button click logger.info("Error button clicked at position (%s, %s)", {x, y}) logger.info("Preparing to trigger multi-frame error...") - + -- Use xpcall to capture the error with original stack trace local function error_handler(err) logger.error("Button click error occurred: %s", {tostring(err)}) - + sentry.capture_exception({ type = "Love2DUserTriggeredError", message = tostring(err) }) - + logger.info("Error captured and sent to Sentry") return err end - + -- Trigger error through multi-level function calls xpcall(function() game.demo_functions.level1("button_click", "button_click") end, error_handler) - + -- Check if click is within fatal button bounds elseif x >= game.fatal_button.x and x <= game.fatal_button.x + game.fatal_button.width and y >= game.fatal_button.y and y <= game.fatal_button.y + game.fatal_button.height then - + game.fatal_button.pressed = true - + -- Add breadcrumb before triggering fatal error sentry.add_breadcrumb({ message = "Fatal error button clicked - will trigger love.errorhandler", @@ -322,11 +322,11 @@ function love.mousepressed(x, y, button_num, istouch, presses) test_type = "fatal_error" } }) - + -- Log the fatal button click logger.info("Fatal error button clicked at position (%s, %s)", {x, y}) logger.info("This will trigger love.errorhandler and crash the app...") - + -- Trigger a fatal error that will go through love.errorhandler -- This error is NOT caught with xpcall, so it will bubble up to love.errorhandler error("Fatal Love2D error triggered by user - Testing love.errorhandler integration!") @@ -346,20 +346,20 @@ function love.keypressed(key) -- Clean shutdown with Sentry flush logger.info("Application shutting down") logger.flush() - + sentry.close() - + love.event.quit() elseif key == "r" then -- Trigger rendering error logger.info("Rendering error triggered via keyboard") - + sentry.add_breadcrumb({ message = "Rendering error triggered", category = "keyboard_interaction", level = "info" }) - + local function error_handler(err) sentry.capture_exception({ type = "Love2DRenderingError", @@ -367,15 +367,15 @@ function love.keypressed(key) }) return err end - + xpcall(function() game.demo_functions.level1("render_test", "rendering") end, error_handler) - + elseif key == "f" then -- Trigger fatal error via keyboard logger.info("Fatal error triggered via keyboard - will crash app") - + sentry.add_breadcrumb({ message = "Fatal error triggered via keyboard (F key)", category = "keyboard_interaction", @@ -384,7 +384,7 @@ function love.keypressed(key) test_type = "fatal_error_keyboard" } }) - + -- This will go through love.errorhandler and crash the app error("Fatal Love2D error triggered by keyboard (F key) - Testing love.errorhandler integration!") end @@ -394,8 +394,8 @@ function love.quit() -- Clean shutdown logger.info("Love2D application quit") logger.flush() - + sentry.close() - + return false -- Allow quit to proceed end \ No newline at end of file diff --git a/examples/roblox/sentry-all-in-one.luau b/examples/roblox/sentry-all-in-one.luau index 7720b9c..b965997 100644 --- a/examples/roblox/sentry-all-in-one.luau +++ b/examples/roblox/sentry-all-in-one.luau @@ -1,18 +1,18 @@ --[[ Sentry All-in-One for Roblox - + Complete Sentry integration using real SDK modules. Generated from built SDK - DO NOT EDIT MANUALLY - + To regenerate: ./scripts/generate-roblox-all-in-one.sh - + USAGE: 1. Copy this entire file - 2. Paste into ServerScriptService as a Script + 2. Paste into ServerScriptService as a Script 3. Update SENTRY_DSN below 4. Enable HTTP requests: Game Settings → Security → "Allow HTTP Requests" 5. Run the game (F5) - + API (same as other platforms): sentry.init({dsn = "your-dsn"}) sentry.capture_message("Player died!", "error") @@ -52,7 +52,7 @@ function json.encode(obj) return HttpService:JSONEncode(obj) end -function json.decode(str) +function json.decode(str) return HttpService:JSONDecode(str) end @@ -66,36 +66,36 @@ function dsn_utils.parse_dsn(dsn_string) if not dsn_string or dsn_string == "" then return {}, "DSN is required" end - + local protocol, credentials, host_path = dsn_string:match("^(https?)://([^@]+)@(.+)$") - + if not protocol or not credentials or not host_path then return {}, "Invalid DSN format" end - + -- Parse credentials (public_key or public_key:secret_key) local public_key, secret_key = credentials:match("^([^:]+):(.+)$") if not public_key then public_key = credentials secret_key = "" end - + if not public_key or public_key == "" then return {}, "Invalid DSN format" end - + -- Parse host and path local host, path = host_path:match("^([^/]+)(.*)$") if not host or not path or path == "" then return {}, "Invalid DSN format" end - + -- Extract project ID from path (last numeric segment) local project_id = path:match("/([%d]+)$") if not project_id then return {}, "Could not extract project ID from DSN" end - + return { protocol = protocol, public_key = public_key, @@ -125,7 +125,7 @@ RobloxTransport.__index = RobloxTransport function RobloxTransport:new() local transport = setmetatable({ dsn = nil, - endpoint = nil, + endpoint = nil, headers = nil }, RobloxTransport) return transport @@ -142,13 +142,13 @@ function RobloxTransport:configure(config) self.headers = { ["X-Sentry-Auth"] = dsn_utils.build_auth_header(dsn), } - + -- Debug DSN configuration print("🔧 TRANSPORT CONFIGURATION DEBUG:") print(" DSN parsed successfully: " .. tostring(dsn.public_key ~= nil)) print(" Endpoint: " .. self.endpoint) print(" Headers configured: " .. tostring(self.headers ~= nil)) - + return self end @@ -157,16 +157,16 @@ function RobloxTransport:send(event) return false, "Not in Roblox environment" end - local success_service, HttpService = pcall(function() - return game:GetService("HttpService") + local success_service, _HttpService = pcall(function() + return game:GetService("HttpService") end) - if not success_service or not HttpService then + if not success_service or not _HttpService then return false, "HttpService not available in Roblox" end local body = json.encode(event) - + -- Debug output: request details print("🌐 HTTP REQUEST DEBUG:") print(" Endpoint: " .. self.endpoint) @@ -184,7 +184,7 @@ function RobloxTransport:send(event) print(" Body preview: " .. string.sub(body, 1, 100) .. "...") local success, response = pcall(function() - return HttpService:PostAsync(self.endpoint, body, + return _HttpService:PostAsync(self.endpoint, body, Enum.HttpContentType.ApplicationJson, false, self.headers) @@ -244,7 +244,7 @@ end function Scope:add_breadcrumb(breadcrumb) breadcrumb.timestamp = os.time() table.insert(self.breadcrumbs, breadcrumb) - + -- Keep only last 50 breadcrumbs if #self.breadcrumbs > 50 then table.remove(self.breadcrumbs, 1) @@ -255,7 +255,7 @@ function Scope:clone() local cloned = Scope:new() cloned.user = self.user cloned.level = self.level - + -- Deep copy tables for k, v in pairs(self.tags) do cloned.tags[k] = v @@ -266,7 +266,7 @@ function Scope:clone() for i, crumb in ipairs(self.breadcrumbs) do cloned.breadcrumbs[i] = crumb end - + return cloned end @@ -281,26 +281,26 @@ function Client:new(config) if not config.dsn then error("DSN is required") end - + local client = setmetatable({ transport = RobloxTransport:new(), scope = Scope:new(), config = config }, Client) - + client.transport:configure(config) - + print("🔧 Sentry client initialized") print(" Environment: " .. (config.environment or "production")) print(" Release: " .. (config.release or "unknown")) print(" SDK Version: " .. version()) - + return client end function Client:capture_message(message, level) level = level or "info" - + local event = { message = { message = message @@ -308,7 +308,7 @@ function Client:capture_message(message, level) level = level, timestamp = os.time(), environment = self.config.environment or "production", - release = self.config.release or "unknown", + release = self.config.release or "unknown", platform = "roblox", sdk = { name = "sentry.lua", @@ -327,19 +327,19 @@ function Client:capture_message(message, level) } } } - + print("📨 Capturing message: " .. message .. " [" .. level .. "]") print("🔄 About to call transport:send...") - + local success, result = self.transport:send(event) print("🔄 Transport call completed. Success: " .. tostring(success) .. ", Result: " .. tostring(result)) - + return success, result end function Client:capture_exception(exception, level) level = level or "error" - + local event = { exception = { values = { @@ -374,13 +374,13 @@ function Client:capture_exception(exception, level) } } } - + print("🚨 Capturing exception: " .. (exception.message or tostring(exception))) print("🔄 About to call transport:send for exception...") - + local success, result = self.transport:send(event) print("🔄 Exception transport call completed. Success: " .. tostring(success) .. ", Result: " .. tostring(result)) - + return success, result end @@ -414,7 +414,7 @@ function sentry.init(config) if not config or not config.dsn then error("Sentry DSN is required") end - + sentry._client = Client:new(config) return sentry._client end @@ -423,7 +423,7 @@ function sentry.capture_message(message, level) if not sentry._client then error("Sentry not initialized. Call sentry.init() first.") end - + return sentry._client:capture_message(message, level) end @@ -431,7 +431,7 @@ function sentry.capture_exception(exception, level) if not sentry._client then error("Sentry not initialized. Call sentry.init() first.") end - + return sentry._client:capture_exception(exception, level) end @@ -496,7 +496,7 @@ sentry.set_tag("platform", "roblox") -- Test extra context sentry.set_extra("test_type", "integration") --- Test breadcrumbs +-- Test breadcrumbs sentry.add_breadcrumb({ message = "Integration test started", category = "test", @@ -545,7 +545,7 @@ if game and game:FindFirstChild("Workspace") then workspace.SentrySDK:SetAttribute("Initialized", true) end --- Force global persistence +-- Force global persistence rawset(_G, "sentry", sentry) -- Debug global variable setup @@ -573,7 +573,7 @@ print("") print("🔹 Try these in order until one works:") print("_G.sentry.capture_message('Hello World!', 'info')") print("rawget(_G, 'sentry').capture_message('Hello rawget!', 'info')") -print("shared.sentry.capture_message('Hello shared!', 'info')") +print("shared.sentry.capture_message('Hello shared!', 'info')") print("getgenv().sentry.capture_message('Hello getgenv!', 'info')") print("") print("🔹 Exception examples:") @@ -587,7 +587,7 @@ print("_G.sentry.add_breadcrumb({message = 'Test action', category = 'test'})") print("") print("✅ Integration ready - uses real SDK " .. version() .. "!") --- Also try alternative global setups for better Roblox compatibility +-- Also try alternative global setups for better Roblox compatibility if getgenv then getgenv().sentry = sentry print("📦 Also available via getgenv().sentry") diff --git a/src/sentry/core/client.lua b/src/sentry/core/client.lua index 54f84dc..35a5f03 100644 --- a/src/sentry/core/client.lua +++ b/src/sentry/core/client.lua @@ -24,12 +24,12 @@ function Client:capture_message(message) diagnostic_logger.error("Transport not initialized") return nil end - + local event = { message = message, level = "info", } - + return self.transport:send_event(event) end From ab563a501421c03ec20d0deeb35af8b9198791ea Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 11:08:45 -0400 Subject: [PATCH 12/23] fix linter issues on all examples --- examples/basic.lua | 5 ++--- examples/love2d/conf.lua | 2 +- examples/love2d/main.lua | 18 +++++++++--------- examples/wrap_demo.lua | 11 +---------- 4 files changed, 13 insertions(+), 23 deletions(-) diff --git a/examples/basic.lua b/examples/basic.lua index bf6495a..8f519f8 100644 --- a/examples/basic.lua +++ b/examples/basic.lua @@ -141,7 +141,7 @@ sentry.with_scope(function(scope) xpcall(safe_string_concat, function(err) sentry.capture_exception({ - type = "TypeError", + type = "TypeError", message = "String concatenation error: " .. tostring(err) }) return err @@ -179,7 +179,6 @@ end) -- Original database error for comparison with parameters local function database_query(query_type, table_name, timeout_ms) - local connection_attempts = 3 local last_error = "Connection timeout after " .. timeout_ms .. "ms" error("Database query failed: " .. query_type .. " on " .. table_name .. " (" .. last_error .. ")") end @@ -227,7 +226,7 @@ sentry.with_scope(function(scope) xpcall(function() manual_error_demo("update", "res_456") end, function(err) sentry.capture_exception({ - type = "ManuallyHandledError", + type = "ManuallyHandledError", message = "Manually captured: " .. tostring(err) }) print("[Manual] Error captured and handled gracefully") diff --git a/examples/love2d/conf.lua b/examples/love2d/conf.lua index e2f31bf..724a630 100644 --- a/examples/love2d/conf.lua +++ b/examples/love2d/conf.lua @@ -34,7 +34,7 @@ function love.conf(t) -- Disable unused modules for faster startup and smaller memory footprint t.modules.audio = true -- Enable audio module - t.modules.data = false -- Enable data module + t.modules.data = false -- Enable data module t.modules.event = true -- Enable event module t.modules.font = true -- Enable font module t.modules.graphics = true -- Enable graphics module diff --git a/examples/love2d/main.lua b/examples/love2d/main.lua index 5e1dcae..7ab9bf9 100644 --- a/examples/love2d/main.lua +++ b/examples/love2d/main.lua @@ -67,7 +67,7 @@ function love.load() print("[Debug] No transport found!") end - -- Initialize logger + -- Initialize logger logger.init({ enable_logs = true, max_buffer_size = 5, @@ -96,7 +96,7 @@ function love.load() -- Top part of S {120, 100}, {180, 100}, {180, 130}, {150, 130}, {150, 150}, {180, 150}, {180, 180}, {120, 180}, {120, 150}, {150, 150}, - -- Bottom part of S + -- Bottom part of S {150, 170}, {120, 170}, {120, 200}, {180, 200} } @@ -129,7 +129,7 @@ function love.load() logger.error("Graphics rendering failure") error("Love2DRenderError: Failed to render game object at frame " .. love.timer.getTime()) else - logger.error("Generic game error occurred") + logger.error("Generic game error occurred") error("Love2DGameError: Unexpected game state error in category " .. tostring(category)) end end @@ -162,7 +162,7 @@ function love.update(dt) if button.hover and not was_hover then logger.debug("Button hover state: entered") elseif not button.hover and was_hover then - logger.debug("Button hover state: exited") + logger.debug("Button hover state: exited") end -- Flush Sentry transport periodically @@ -222,7 +222,7 @@ function love.draw() love.graphics.setColor(1, 1, 1, 1) local text_width = game.button_font:getWidth(button.text) local text_height = game.button_font:getHeight() - love.graphics.print(button.text, + love.graphics.print(button.text, button.x + (button.width - text_width) / 2, button.y + (button.height - text_height) / 2) @@ -245,7 +245,7 @@ function love.draw() love.graphics.setFont(game.button_font) love.graphics.setColor(1, 1, 1, 1) local fatal_text_width = game.button_font:getWidth(fatal_button.text) - love.graphics.print(fatal_button.text, + love.graphics.print(fatal_button.text, fatal_button.x + (fatal_button.width - fatal_text_width) / 2, fatal_button.y + (fatal_button.height - text_height) / 2) @@ -274,7 +274,7 @@ function love.mousepressed(x, y, button_num, istouch, presses) -- Add breadcrumb before triggering error sentry.add_breadcrumb({ message = "Error button clicked", - category = "user_interaction", + category = "user_interaction", level = "info", data = { mouse_x = x, @@ -314,7 +314,7 @@ function love.mousepressed(x, y, button_num, istouch, presses) -- Add breadcrumb before triggering fatal error sentry.add_breadcrumb({ message = "Fatal error button clicked - will trigger love.errorhandler", - category = "user_interaction", + category = "user_interaction", level = "warning", data = { mouse_x = x, @@ -378,7 +378,7 @@ function love.keypressed(key) sentry.add_breadcrumb({ message = "Fatal error triggered via keyboard (F key)", - category = "keyboard_interaction", + category = "keyboard_interaction", level = "warning", data = { test_type = "fatal_error_keyboard" diff --git a/examples/wrap_demo.lua b/examples/wrap_demo.lua index 676e4d1..3873088 100644 --- a/examples/wrap_demo.lua +++ b/examples/wrap_demo.lua @@ -32,17 +32,12 @@ local function process_database_config(environment, service_name, retry_count) end local function validate_user_permissions(user_id, action_type, resource_id) - local permissions = {"read", "write", "admin"} - local session_data = {expires_at = os.time() + 3600, csrf_token = "abc123"} - -- Process permissions validation return process_database_config("production", "user_service", 3) end local function main(app_version, startup_mode) print("=== Sentry.wrap() Demo === (v" .. app_version .. ", mode: " .. startup_mode .. ")") - local instance_id = "inst_" .. math.random(1000, 9999) - -- Add some context sentry.add_breadcrumb({ message = "Application started", @@ -95,8 +90,6 @@ print("\n=== Method 2: Custom Error Handler ===") -- Method 2: Custom error handler - You can handle errors gracefully local function attempt_risky_operation(operation_id, max_retries, timeout_seconds) local cache_key = "op_" .. operation_id - local start_time = os.time() - print("Attempting risky operation (ID: " .. operation_id .. ", retries: " .. max_retries .. ")...") local risky_data = nil @@ -132,8 +125,6 @@ print("\n=== Comparison with Manual Approach ===") -- Show equivalent manual approach for comparison local function manual_error_simulation(task_name, priority_level) local task_id = "task_" .. math.random(100, 999) - local worker_id = "worker_A1" - error("Manual error handling for " .. task_name .. " (priority: " .. priority_level .. ", task: " .. task_id .. ")") end @@ -156,6 +147,6 @@ sentry.close() print("\n=== Summary ===") print("• sentry.wrap(main_function) - Simple automatic error capture") -print("• sentry.wrap(main_function, error_handler) - Custom error handling") +print("• sentry.wrap(main_function, error_handler) - Custom error handling") print("• All Sentry context (user, tags, breadcrumbs) is automatically included") print("• Much simpler than manually wrapping every error-prone operation") \ No newline at end of file From b549295ed0b03f7e10248149fd6f2ce2094c01b0 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 11:43:01 -0400 Subject: [PATCH 13/23] all but lua.lua linter issues fixed --- .luacheckrc | 37 ++++++++++++++++++++------------- src/sentry/core/client.lua | 2 +- src/sentry/core/dsn.lua | 6 +++--- src/sentry/platforms/init.lua | 2 +- src/sentry/platforms/love2d.lua | 2 ++ src/sentry/platforms/lua.lua | 12 +++++------ 6 files changed, 34 insertions(+), 27 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index a92fc28..21b4f95 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -1,7 +1,5 @@ --- Luacheck configuration file - --- Standard library compatibility -std = "lua51+lua52+lua53+lua54+luajit" +-- default is: max +-- std = "lua51+lua52+lua53+lua54+luajit" exclude_files = { ".luarocks/", @@ -17,22 +15,31 @@ files["spec/"] = { } } -files["**/love2d*"] = { - globals = { - "love", - } -} +local love = { "love", } +files["**/love2d*"] = { globals = love } -files["**/roblox*"] = { - globals = { - "game", "DateTime", "task", "Instance", "getgenv", "shared", "Enum" - } -} +local roblox = { "game", "DateTime", "task", "Instance", "getgenv", "shared", "Enum" } +files["**/roblox*"] = { globals = roblox } + +local defold = { "http", } +files["**/defold*"] = { globals = defold } + +local openresty = { "ngx", } +files["**/openresty*"] = { globals = openresty } + +local platform_bootstrapper = {} +local n = 0 +for _, t in ipairs{love, roblox, defold, openresty} do + table.move(t, 1, #t, n + 1, platform_bootstrapper) + n = n + #t +end + +files["src/sentry/platforms/init.lua"] = { globals = platform_bootstrapper } ignore = { "212", -- unused argument (common in callbacks) "213", -- unused loop variable (common in iterations) } -max_line_length = 160 +max_line_length = 200 max_cyclomatic_complexity = 20 \ No newline at end of file diff --git a/src/sentry/core/client.lua b/src/sentry/core/client.lua index 35a5f03..b1b3cbc 100644 --- a/src/sentry/core/client.lua +++ b/src/sentry/core/client.lua @@ -4,7 +4,7 @@ local Client = {} Client.__index = Client function Client:new(options) - if not options then + if not options then diagnostic_logger.warn("Cannot create a Sentry client without options.") return nil end diff --git a/src/sentry/core/dsn.lua b/src/sentry/core/dsn.lua index 34e768c..ed12f27 100644 --- a/src/sentry/core/dsn.lua +++ b/src/sentry/core/dsn.lua @@ -63,9 +63,9 @@ local function parse_dsn(dsn_string) end local function build_envelope_url(dsn) - return string.format("%s://%s/api/%s/envelope/", - dsn.protocol, - dsn.host, + return string.format("%s://%s/api/%s/envelope/", + dsn.protocol, + dsn.host, dsn.project_id) end diff --git a/src/sentry/platforms/init.lua b/src/sentry/platforms/init.lua index e53a577..05b803e 100644 --- a/src/sentry/platforms/init.lua +++ b/src/sentry/platforms/init.lua @@ -42,7 +42,7 @@ local function debug(err) end local function get() - if not M then + if not M then local ok, val = xpcall(detect, debug) if not ok then error("failed to load Sentry") end M = val diff --git a/src/sentry/platforms/love2d.lua b/src/sentry/platforms/love2d.lua index 2569188..9076a2d 100644 --- a/src/sentry/platforms/love2d.lua +++ b/src/sentry/platforms/love2d.lua @@ -98,6 +98,8 @@ function M.http_post(url, body, headers, opts) end function M.http_post_async(url, body, headers, opts, callback) + -- luacheck: no unused secondaries + ---@diagnostic disable-next-line: unused-local local ok, status, resp_body, resp_headers = M.http_post(url, body, headers, opts) if callback then callback(ok, status, resp_body) diff --git a/src/sentry/platforms/lua.lua b/src/sentry/platforms/lua.lua index 7a3bf59..fc6115e 100644 --- a/src/sentry/platforms/lua.lua +++ b/src/sentry/platforms/lua.lua @@ -65,6 +65,8 @@ local function request_with_optional_headers(lib, url, method, headers, body, ti if ltn12 then local chunks = {} + -- luacheck: no unused secondaries + ---@diagnostic disable-next-line: unused-local local ok, code, resp_headers, status = lib.request{ url = url, method = method, @@ -72,15 +74,10 @@ local function request_with_optional_headers(lib, url, method, headers, body, ti source = ltn12.source.string(body), sink = ltn12.sink.table(chunks), } - if not ok then return nil, tostring(code or "request failed") end + if not ok then return nil, tostring(code or "request failed: ") end return true, tonumber(code), table.concat(chunks), resp_headers end - -- No ltn12: reject custom headers; allow body via 2-arg form - for _ in pairs(headers) do - return nil, "custom headers require ltn12 (install ltn12)" - end - local resp_body, code, resp_headers = lib.request(url, body) if not resp_body then return nil, tostring(code or "request failed") end return true, tonumber(code), resp_body, resp_headers @@ -88,11 +85,12 @@ end function M.http_post(url, body, headers, opts) opts = opts or {} - local scheme = url:match("^([%a][%w+.-]*)://") or error("Only https connection is supported.") return request_with_optional_headers(https, url, "POST", headers, body, opts.timeout_ms) end function M.http_post_async(url, body, headers, opts, callback) + -- luacheck: no unused secondaries + ---@diagnostic disable-next-line: unused-local local ok, status, resp_body, resp_headers = M.http_post(url, body, headers, opts) if callback then callback(ok, status, resp_body) From c175b4288316d30a89a8d17b2c4501f904814254 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 11:47:56 -0400 Subject: [PATCH 14/23] all linting issues fixed --- src/sentry/platforms/lua.lua | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/sentry/platforms/lua.lua b/src/sentry/platforms/lua.lua index fc6115e..2e68025 100644 --- a/src/sentry/platforms/lua.lua +++ b/src/sentry/platforms/lua.lua @@ -37,15 +37,23 @@ local https = safe_require("ssl.https") if not https then error("https not available (install LuaSec: ssl.https)") end local ltn12 = safe_require("ltn12") +local socket = safe_require("socket") local M = { name = "lua" } function M.timestamp() local now, sec, ms - if socket_ok and type(socket.gettime) == "function" then - now = socket.gettime(); sec = now // 1; ms = math.floor((now - sec)*1000 + 0.5) - if ms == 1000 then ms = 0; sec = sec + 1 end + -- Try socket.gettime() for millisecond precision + if socket and type(socket.gettime) == "function" then + now = socket.gettime() + sec = math.floor(now) + ms = math.floor((now - sec) * 1000 + 0.5) + if ms >= 1000 then + ms = 0 + sec = sec + 1 + end else + -- Fallback to os.time() without millisecond precision sec, ms = os.time(), 0 end local d = os.date("*t", sec) From 6bda582191e1722149f906d0db7f480a9d17f0fe Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 11:49:25 -0400 Subject: [PATCH 15/23] lint in CI --- .github/workflows/test.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e6d28ce..83a9487 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -16,6 +16,25 @@ env: LUAJIT_2_1_VERSION: "v2.1.0-beta3" jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + + - name: Setup Lua + uses: leafo/gh-actions-lua@35bcb06abec04ec87df82e08caa84d545348536e # v10 + with: + luaVersion: "5.4" + + - name: Setup LuaRocks + uses: leafo/gh-actions-luarocks@e65774a6386cb4f24e293dca7fc4ff89165b64c5 # v4 + + - name: Install luacheck + run: luarocks install luacheck + + - name: Run luacheck + run: luacheck . + test: strategy: fail-fast: false From 2d1e8a37c97f30fc9d8195e1c49b6e869db29c9b Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 16:10:07 -0400 Subject: [PATCH 16/23] stylua run --- .github/workflows/test.yml | 6 + .styluaignore | 9 + examples/basic.lua | 266 +++++----- examples/love2d/conf.lua | 96 ++-- examples/love2d/main.lua | 713 +++++++++++++------------- examples/wrap_demo.lua | 158 +++--- spec/dsn_parsing_spec.lua | 482 +++++++++-------- src/sentry/core/client.lua | 46 +- src/sentry/core/diagnostic_logger.lua | 14 +- src/sentry/core/dsn.lua | 117 ++--- src/sentry/core/scope.lua | 46 +- src/sentry/core/transport.lua | 108 ++-- src/sentry/core/version.lua | 2 +- src/sentry/init.lua | 146 +++--- src/sentry/platforms/defold.lua | 31 +- src/sentry/platforms/init.lua | 84 ++- src/sentry/platforms/love2d.lua | 50 +- src/sentry/platforms/lua.lua | 39 +- src/sentry/platforms/openresty.lua | 33 +- stylua.toml | 14 + 20 files changed, 1201 insertions(+), 1259 deletions(-) create mode 100644 .styluaignore create mode 100644 stylua.toml diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 83a9487..9e6f2a2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,12 @@ jobs: - name: Setup LuaRocks uses: leafo/gh-actions-luarocks@e65774a6386cb4f24e293dca7fc4ff89165b64c5 # v4 + - name: Install StyLua + uses: JohnnyMorganz/stylua-action@479972f01e665acfcba96ada452c36608bdbbb5e # v4.1.0 + with: + version: latest + args: --check . + - name: Install luacheck run: luarocks install luacheck diff --git a/.styluaignore b/.styluaignore new file mode 100644 index 0000000..5deb064 --- /dev/null +++ b/.styluaignore @@ -0,0 +1,9 @@ +# Ignore generated or third-party files +.luarocks/ +.git/ + +# Ignore any vendor or external dependencies +vendor/ + +# Ignore specific files if needed (uncomment as needed) +# examples/some-example.lua \ No newline at end of file diff --git a/examples/basic.lua b/examples/basic.lua index 8f519f8..1c25ed1 100644 --- a/examples/basic.lua +++ b/examples/basic.lua @@ -3,17 +3,17 @@ local sentry = require("sentry") -- Initialize Sentry with your DSN sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - environment = "production", - release = "wrap-demo@1.0", - debug = true, + dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", + environment = "production", + release = "wrap-demo@1.0", + debug = true, }) -- Set user context sentry.set_user({ - id = "user123", - email = "user@example.com", - username = "testuser" + id = "user123", + email = "user@example.com", + username = "testuser", }) -- Add tags for filtering @@ -26,31 +26,27 @@ sentry.set_extra("request_id", "req456") -- Add breadcrumbs for debugging context sentry.add_breadcrumb({ - message = "User started checkout process", - category = "navigation", - level = "info" + message = "User started checkout process", + category = "navigation", + level = "info", }) -- Helper functions to create deeper stack traces with parameters -local function send_confirmation_email(user_email, order_id, amount) - sentry.capture_message("Purchase confirmation sent to " .. user_email .. " for order #" .. order_id, "info") -end +local function send_confirmation_email(user_email, order_id, amount) sentry.capture_message("Purchase confirmation sent to " .. user_email .. " for order #" .. order_id, "info") end local function process_payment(payment_method, amount, currency) - local order_id = "ORD-" .. math.random(1000, 9999) - send_confirmation_email("customer@example.com", order_id, amount) + local order_id = "ORD-" .. math.random(1000, 9999) + send_confirmation_email("customer@example.com", order_id, amount) end local function validate_cart(items_count, total_price) - if items_count > 0 then - process_payment("credit_card", total_price, "USD") - end + if items_count > 0 then process_payment("credit_card", total_price, "USD") end end local function checkout_handler(user_id, session_token) - local cart_items = 3 - local total = 149.99 - validate_cart(cart_items, total) + local cart_items = 3 + local total = 149.99 + validate_cart(cart_items, total) end -- Capture a message with multiple stack frames and parameters @@ -60,178 +56,164 @@ checkout_handler("user_12345", "sess_abcdef123456") -- Common pitfall: Using 0-based indexing (Lua uses 1-based) local function array_access_error(department, min_users_required) - local users = {"alice", "bob", "charlie"} - local debug_info = "Checking " .. department .. " (need " .. min_users_required .. " users)" + local users = { "alice", "bob", "charlie" } + local debug_info = "Checking " .. department .. " (need " .. min_users_required .. " users)" - -- This will access nil (common mistake from other languages) - local first_user = users[0] -- Should be users[1] + -- This will access nil (common mistake from other languages) + local first_user = users[0] -- Should be users[1] - -- This will cause an error when trying to use nil - return string.upper(first_user) .. " in " .. debug_info + -- This will cause an error when trying to use nil + return string.upper(first_user) .. " in " .. debug_info end -- Common pitfall: Using + for string concatenation instead of .. local function string_concat_error(greeting_type, username, user_id) - local timestamp = os.time() - local session_id = "sess_" .. math.random(1000, 9999) + local timestamp = os.time() + local session_id = "sess_" .. math.random(1000, 9999) - -- Wrong: using + instead of .. for concatenation - local message = greeting_type + " " + username + " (ID: " + user_id + ") at " + timestamp - return message .. " session: " .. session_id + -- Wrong: using + instead of .. for concatenation + local message = greeting_type + " " + username + " (ID: " + user_id + ") at " + timestamp + return message .. " session: " .. session_id end -- Common pitfall: Calling methods on nil values local function nil_method_call(service_name, environment) - local config = nil -- Simulate missing configuration - local default_timeout = 30 + local config = nil -- Simulate missing configuration + local default_timeout = 30 - -- This will error: attempt to index a nil value - local timeout = config.timeout or default_timeout - return "Service " .. service_name .. " (" .. environment .. ") timeout: " .. timeout + -- This will error: attempt to index a nil value + local timeout = config.timeout or default_timeout + return "Service " .. service_name .. " (" .. environment .. ") timeout: " .. timeout end -- Common pitfall: Incorrect table iteration local function table_iteration_error(record_type, max_items) - local data = {name = "test_record", value = 42, priority = "high"} - local result = "" - local processed_count = 0 + local data = { name = "test_record", value = 42, priority = "high" } + local result = "" + local processed_count = 0 - -- Wrong: using ipairs on a hash table (should use pairs) - for i, v in ipairs(data) do - result = result .. tostring(v) - processed_count = processed_count + 1 - end + -- Wrong: using ipairs on a hash table (should use pairs) + for i, v in ipairs(data) do + result = result .. tostring(v) + processed_count = processed_count + 1 + end - return record_type .. ": processed " .. processed_count .. "/" .. max_items .. " -> " .. result + return record_type .. ": processed " .. processed_count .. "/" .. max_items .. " -> " .. result end -- Demonstrate each error type with proper context sentry.add_breadcrumb({ - message = "Starting error demonstration", - category = "demo", - level = "info" + message = "Starting error demonstration", + category = "demo", + level = "info", }) -- Test array indexing error sentry.with_scope(function(scope) - scope:set_tag("error_type", "array_indexing") - scope:set_extra("expected_behavior", "Lua arrays are 1-indexed, not 0-indexed") - - local function safe_array_access() - array_access_error("engineering", 2) - end - - xpcall(safe_array_access, function(err) - sentry.capture_exception({ - type = "IndexError", - message = "Array indexing error: " .. tostring(err) - }) - return err - end) + scope:set_tag("error_type", "array_indexing") + scope:set_extra("expected_behavior", "Lua arrays are 1-indexed, not 0-indexed") + + local function safe_array_access() array_access_error("engineering", 2) end + + xpcall(safe_array_access, function(err) + sentry.capture_exception({ + type = "IndexError", + message = "Array indexing error: " .. tostring(err), + }) + return err + end) end) -- Test string concatenation error sentry.with_scope(function(scope) - scope:set_tag("error_type", "string_concatenation") - scope:set_extra("expected_behavior", "Lua uses .. for string concatenation, not +") - - local function safe_string_concat() - string_concat_error("Hello", "john_doe", 42) - end - - xpcall(safe_string_concat, function(err) - sentry.capture_exception({ - type = "TypeError", - message = "String concatenation error: " .. tostring(err) - }) - return err - end) + scope:set_tag("error_type", "string_concatenation") + scope:set_extra("expected_behavior", "Lua uses .. for string concatenation, not +") + + local function safe_string_concat() string_concat_error("Hello", "john_doe", 42) end + + xpcall(safe_string_concat, function(err) + sentry.capture_exception({ + type = "TypeError", + message = "String concatenation error: " .. tostring(err), + }) + return err + end) end) -- Test nil method call error sentry.with_scope(function(scope) - scope:set_tag("error_type", "nil_access") - scope:set_extra("expected_behavior", "Always check for nil before accessing table fields") - - local function safe_nil_access() - nil_method_call("database", "production") - end - - xpcall(safe_nil_access, function(err) - sentry.capture_exception({ - type = "NilAccessError", - message = "Nil access error: " .. tostring(err) - }) - return err - end) + scope:set_tag("error_type", "nil_access") + scope:set_extra("expected_behavior", "Always check for nil before accessing table fields") + + local function safe_nil_access() nil_method_call("database", "production") end + + xpcall(safe_nil_access, function(err) + sentry.capture_exception({ + type = "NilAccessError", + message = "Nil access error: " .. tostring(err), + }) + return err + end) end) -- Test table iteration error (this one might not error but produces wrong results) sentry.with_scope(function(scope) - scope:set_tag("error_type", "iteration_logic") - scope:set_extra("expected_behavior", "Use pairs() for hash tables, ipairs() for arrays") + scope:set_tag("error_type", "iteration_logic") + scope:set_extra("expected_behavior", "Use pairs() for hash tables, ipairs() for arrays") - local result = table_iteration_error("user_profile", 10) - if result:find("processed 0/") then - sentry.capture_message("Table iteration produced empty result - likely using wrong iterator", "warning") - end + local result = table_iteration_error("user_profile", 10) + if result:find("processed 0/") then sentry.capture_message("Table iteration produced empty result - likely using wrong iterator", "warning") end end) -- Original database error for comparison with parameters local function database_query(query_type, table_name, timeout_ms) - local last_error = "Connection timeout after " .. timeout_ms .. "ms" - error("Database query failed: " .. query_type .. " on " .. table_name .. " (" .. last_error .. ")") + local last_error = "Connection timeout after " .. timeout_ms .. "ms" + error("Database query failed: " .. query_type .. " on " .. table_name .. " (" .. last_error .. ")") end -local function fetch_user_data(user_id, include_preferences) - database_query("SELECT", "users", 5000) -end +local function fetch_user_data(user_id, include_preferences) database_query("SELECT", "users", 5000) end -local function authenticate_user(username, password_hash) - fetch_user_data("user_12345", true) -end +local function authenticate_user(username, password_hash) fetch_user_data("user_12345", true) end -local function handle_request(request_id, client_ip) - authenticate_user("john.doe", "sha256_abc123") -end +local function handle_request(request_id, client_ip) authenticate_user("john.doe", "sha256_abc123") end -- Capture the original authentication error local function error_handler(err) - sentry.capture_exception({ - type = "AuthenticationError", - message = err - }) - return err + sentry.capture_exception({ + type = "AuthenticationError", + message = err, + }) + return err end xpcall(function() handle_request("req_789", "192.168.1.100") end, error_handler) -- Demonstrate automatic error capture vs manual handling sentry.add_breadcrumb({ - message = "About to demonstrate automatic vs manual error handling", - category = "demo", - level = "info" + message = "About to demonstrate automatic vs manual error handling", + category = "demo", + level = "info", }) -- Example 1: Manual error handling with xpcall (traditional approach) print("\n=== Manual Error Handling ===") sentry.with_scope(function(scope) - scope:set_tag("handling_method", "manual") - - local function manual_error_demo(operation_type, resource_id) - local data = nil - local context = "Processing " .. operation_type .. " for resource " .. resource_id - return data.missing_field .. " (" .. context .. ")" -- Will cause nil access error - end - - xpcall(function() manual_error_demo("update", "res_456") end, function(err) - sentry.capture_exception({ - type = "ManuallyHandledError", - message = "Manually captured: " .. tostring(err) - }) - print("[Manual] Error captured and handled gracefully") - return err - end) + scope:set_tag("handling_method", "manual") + + local function manual_error_demo(operation_type, resource_id) + local data = nil + local context = "Processing " .. operation_type .. " for resource " .. resource_id + return data.missing_field .. " (" .. context .. ")" -- Will cause nil access error + end + + xpcall(function() manual_error_demo("update", "res_456") end, function(err) + sentry.capture_exception({ + type = "ManuallyHandledError", + message = "Manually captured: " .. tostring(err), + }) + print("[Manual] Error captured and handled gracefully") + return err + end) end) -- Example 2: Automatic error capture (new functionality) @@ -239,22 +221,22 @@ end) print("\n=== Automatic Error Handling ===") print("The following error will be automatically captured by Sentry:") sentry.with_scope(function(scope) - scope:set_tag("handling_method", "automatic") - scope:set_extra("note", "This error is automatically captured without xpcall") + scope:set_tag("handling_method", "automatic") + scope:set_extra("note", "This error is automatically captured without xpcall") - -- Uncomment the next line to test automatic capture - -- WARNING: This will terminate the program! - -- error("This error is automatically captured!") + -- Uncomment the next line to test automatic capture + -- WARNING: This will terminate the program! + -- error("This error is automatically captured!") - print("[Automatic] Error capture is enabled - any unhandled error() calls are automatically sent to Sentry") + print("[Automatic] Error capture is enabled - any unhandled error() calls are automatically sent to Sentry") end) -- Use scoped context for temporary changes sentry.with_scope(function(scope) - scope:set_tag("temporary", "value") - sentry.capture_message("OS Detection Test Message", "warning") - -- Temporary context is automatically restored + scope:set_tag("temporary", "value") + sentry.capture_message("OS Detection Test Message", "warning") + -- Temporary context is automatically restored end) -- Clean up -sentry:close() \ No newline at end of file +sentry:close() diff --git a/examples/love2d/conf.lua b/examples/love2d/conf.lua index 724a630..64ab480 100644 --- a/examples/love2d/conf.lua +++ b/examples/love2d/conf.lua @@ -1,54 +1,54 @@ -- Love2D configuration for Sentry demo function love.conf(t) - t.identity = "sentry-love2d-demo" -- Save directory name - t.appendidentity = false -- Search files in source directory before save directory - t.version = "11.5" -- LÖVE version this game was made for - t.console = false -- Attach a console (Windows only) - t.accelerometerjoystick = false -- Enable accelerometer on mobile devices - t.externalstorage = false -- True to save files outside of the app folder - t.gammacorrect = false -- Enable gamma-correct rendering + t.identity = "sentry-love2d-demo" -- Save directory name + t.appendidentity = false -- Search files in source directory before save directory + t.version = "11.5" -- LÖVE version this game was made for + t.console = false -- Attach a console (Windows only) + t.accelerometerjoystick = false -- Enable accelerometer on mobile devices + t.externalstorage = false -- True to save files outside of the app folder + t.gammacorrect = false -- Enable gamma-correct rendering - t.audio.mic = false -- Request microphone permission - t.audio.mixwithsystem = true -- Keep background music playing + t.audio.mic = false -- Request microphone permission + t.audio.mixwithsystem = true -- Keep background music playing - t.window.title = "Love2D Sentry Integration Demo" - t.window.icon = nil -- Icon file path - t.window.width = 800 -- Window width - t.window.height = 600 -- Window height - t.window.borderless = false -- Remove window border - t.window.resizable = false -- Allow window resizing - t.window.minwidth = 1 -- Minimum window width - t.window.minheight = 1 -- Minimum window height - t.window.fullscreen = false -- Enable fullscreen - t.window.fullscreentype = "desktop" -- Choose desktop or exclusive fullscreen - t.window.vsync = 1 -- Vertical sync mode (0=off, 1=on, -1=adaptive) - t.window.msaa = 0 -- MSAA samples - t.window.depth = nil -- Depth buffer bit depth - t.window.stencil = nil -- Stencil buffer bit depth - t.window.display = 1 -- Monitor to display window on - t.window.highdpi = false -- Enable high-dpi mode - t.window.usedpiscale = true -- Enable automatic DPI scaling - t.window.x = nil -- Window position x-coordinate - t.window.y = nil -- Window position y-coordinate + t.window.title = "Love2D Sentry Integration Demo" + t.window.icon = nil -- Icon file path + t.window.width = 800 -- Window width + t.window.height = 600 -- Window height + t.window.borderless = false -- Remove window border + t.window.resizable = false -- Allow window resizing + t.window.minwidth = 1 -- Minimum window width + t.window.minheight = 1 -- Minimum window height + t.window.fullscreen = false -- Enable fullscreen + t.window.fullscreentype = "desktop" -- Choose desktop or exclusive fullscreen + t.window.vsync = 1 -- Vertical sync mode (0=off, 1=on, -1=adaptive) + t.window.msaa = 0 -- MSAA samples + t.window.depth = nil -- Depth buffer bit depth + t.window.stencil = nil -- Stencil buffer bit depth + t.window.display = 1 -- Monitor to display window on + t.window.highdpi = false -- Enable high-dpi mode + t.window.usedpiscale = true -- Enable automatic DPI scaling + t.window.x = nil -- Window position x-coordinate + t.window.y = nil -- Window position y-coordinate - -- Disable unused modules for faster startup and smaller memory footprint - t.modules.audio = true -- Enable audio module - t.modules.data = false -- Enable data module - t.modules.event = true -- Enable event module - t.modules.font = true -- Enable font module - t.modules.graphics = true -- Enable graphics module - t.modules.image = false -- Enable image module - t.modules.joystick = false -- Enable joystick module - t.modules.keyboard = true -- Enable keyboard module - t.modules.math = false -- Enable math module - t.modules.mouse = true -- Enable mouse module - t.modules.physics = false -- Enable physics (Box2D) module - t.modules.sound = false -- Enable sound module - t.modules.system = true -- Enable system module - t.modules.thread = true -- Enable thread module (needed for HTTP requests) - t.modules.timer = true -- Enable timer module - t.modules.touch = false -- Enable touch module - t.modules.video = false -- Enable video module - t.modules.window = true -- Enable window module -end \ No newline at end of file + -- Disable unused modules for faster startup and smaller memory footprint + t.modules.audio = true -- Enable audio module + t.modules.data = false -- Enable data module + t.modules.event = true -- Enable event module + t.modules.font = true -- Enable font module + t.modules.graphics = true -- Enable graphics module + t.modules.image = false -- Enable image module + t.modules.joystick = false -- Enable joystick module + t.modules.keyboard = true -- Enable keyboard module + t.modules.math = false -- Enable math module + t.modules.mouse = true -- Enable mouse module + t.modules.physics = false -- Enable physics (Box2D) module + t.modules.sound = false -- Enable sound module + t.modules.system = true -- Enable system module + t.modules.thread = true -- Enable thread module (needed for HTTP requests) + t.modules.timer = true -- Enable timer module + t.modules.touch = false -- Enable touch module + t.modules.video = false -- Enable video module + t.modules.window = true -- Enable window module +end diff --git a/examples/love2d/main.lua b/examples/love2d/main.lua index 7ab9bf9..dc43818 100644 --- a/examples/love2d/main.lua +++ b/examples/love2d/main.lua @@ -9,393 +9,382 @@ local logger = require("sentry.logger") -- Game state local game = { - font_large = nil, - font_small = nil, - button_font = nil, - - -- Sentry logo data (simple representation) - logo_points = {}, - - -- Button state - button = { - x = 250, - y = 400, - width = 160, - height = 60, - text = "Trigger Error", - hover = false, - pressed = false - }, - - -- Fatal error button - fatal_button = { - x = 430, - y = 400, - width = 160, - height = 60, - text = "Fatal Error", - hover = false, - pressed = false - }, - - -- Error state - error_count = 0, - last_error_time = 0, - - -- Demo functions for stack trace - demo_functions = {} + font_large = nil, + font_small = nil, + button_font = nil, + + -- Sentry logo data (simple representation) + logo_points = {}, + + -- Button state + button = { + x = 250, + y = 400, + width = 160, + height = 60, + text = "Trigger Error", + hover = false, + pressed = false, + }, + + -- Fatal error button + fatal_button = { + x = 430, + y = 400, + width = 160, + height = 60, + text = "Fatal Error", + hover = false, + pressed = false, + }, + + -- Error state + error_count = 0, + last_error_time = 0, + + -- Demo functions for stack trace + demo_functions = {}, } function love.load() - -- Initialize window - love.window.setTitle("Love2D Sentry Integration Demo") - love.window.setMode(800, 600, {resizable = false}) - - -- Initialize Sentry - sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - environment = "love2d-demo", - release = "love2d-example@1.0.0", - debug = true - }) - - -- Debug: Check which transport is being used - if sentry._client and sentry._client.transport then - print("[Debug] Transport type:", type(sentry._client.transport)) - print("[Debug] Transport table:", sentry._client.transport) - else - print("[Debug] No transport found!") - end - - -- Initialize logger - logger.init({ - enable_logs = true, - max_buffer_size = 5, - flush_timeout = 2.0, - hook_print = true - }) - - -- Set user context - sentry.set_user({ - id = "love2d_user_" .. math.random(1000, 9999), - username = "love2d_demo_user" - }) - - -- Add tags for filtering - sentry.set_tag("framework", "love2d") - sentry.set_tag("version", table.concat({love.getVersion()}, ".")) - sentry.set_tag("platform", love.system.getOS()) - - -- Load fonts - game.font_large = love.graphics.newFont(32) - game.font_small = love.graphics.newFont(16) - game.button_font = love.graphics.newFont(18) - - -- Create Sentry logo points (simple S shape) - game.logo_points = { - -- Top part of S - {120, 100}, {180, 100}, {180, 130}, {150, 130}, {150, 150}, - {180, 150}, {180, 180}, {120, 180}, {120, 150}, {150, 150}, - -- Bottom part of S - {150, 170}, {120, 170}, {120, 200}, {180, 200} - } - - -- Initialize demo functions for multi-frame stack traces - game.demo_functions = { - level1 = function(user_action, error_type) - logger.info("Level 1: Processing user action %s", {user_action}) - return game.demo_functions.level2(error_type, "processing") - end, - - level2 = function(action_type, status) - logger.debug("Level 2: Executing %s with status %s", {action_type, status}) - return game.demo_functions.level3(action_type) - end, - - level3 = function(error_category) - logger.warn("Level 3: About to trigger %s error", {error_category}) - return game.demo_functions.trigger_error(error_category) - end, - - trigger_error = function(category) - game.error_count = game.error_count + 1 - game.last_error_time = love.timer.getTime() - - -- Create realistic error scenarios - if category == "button_click" then - logger.error("Critical error in button handler") - error("Love2DButtonError: Button click handler failed with code " .. math.random(1000, 9999)) - elseif category == "rendering" then - logger.error("Graphics rendering failure") - error("Love2DRenderError: Failed to render game object at frame " .. love.timer.getTime()) - else - logger.error("Generic game error occurred") - error("Love2DGameError: Unexpected game state error in category " .. tostring(category)) - end - end - } - - -- Log successful initialization - logger.info("Love2D Sentry demo initialized successfully") - logger.info("Love2D version: %s", {table.concat({love.getVersion()}, ".")}) - logger.info("Operating system: %s", {love.system.getOS()}) - - -- Add breadcrumb for debugging context - sentry.add_breadcrumb({ - message = "Love2D game initialized", - category = "game_lifecycle", - level = "info" - }) + -- Initialize window + love.window.setTitle("Love2D Sentry Integration Demo") + love.window.setMode(800, 600, { resizable = false }) + + -- Initialize Sentry + sentry.init({ + dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", + environment = "love2d-demo", + release = "love2d-example@1.0.0", + debug = true, + }) + + -- Debug: Check which transport is being used + if sentry._client and sentry._client.transport then + print("[Debug] Transport type:", type(sentry._client.transport)) + print("[Debug] Transport table:", sentry._client.transport) + else + print("[Debug] No transport found!") + end + + -- Initialize logger + logger.init({ + enable_logs = true, + max_buffer_size = 5, + flush_timeout = 2.0, + hook_print = true, + }) + + -- Set user context + sentry.set_user({ + id = "love2d_user_" .. math.random(1000, 9999), + username = "love2d_demo_user", + }) + + -- Add tags for filtering + sentry.set_tag("framework", "love2d") + sentry.set_tag("version", table.concat({ love.getVersion() }, ".")) + sentry.set_tag("platform", love.system.getOS()) + + -- Load fonts + game.font_large = love.graphics.newFont(32) + game.font_small = love.graphics.newFont(16) + game.button_font = love.graphics.newFont(18) + + -- Create Sentry logo points (simple S shape) + game.logo_points = { + -- Top part of S + { 120, 100 }, + { 180, 100 }, + { 180, 130 }, + { 150, 130 }, + { 150, 150 }, + { 180, 150 }, + { 180, 180 }, + { 120, 180 }, + { 120, 150 }, + { 150, 150 }, + -- Bottom part of S + { 150, 170 }, + { 120, 170 }, + { 120, 200 }, + { 180, 200 }, + } + + -- Initialize demo functions for multi-frame stack traces + game.demo_functions = { + level1 = function(user_action, error_type) + logger.info("Level 1: Processing user action %s", { user_action }) + return game.demo_functions.level2(error_type, "processing") + end, + + level2 = function(action_type, status) + logger.debug("Level 2: Executing %s with status %s", { action_type, status }) + return game.demo_functions.level3(action_type) + end, + + level3 = function(error_category) + logger.warn("Level 3: About to trigger %s error", { error_category }) + return game.demo_functions.trigger_error(error_category) + end, + + trigger_error = function(category) + game.error_count = game.error_count + 1 + game.last_error_time = love.timer.getTime() + + -- Create realistic error scenarios + if category == "button_click" then + logger.error("Critical error in button handler") + error("Love2DButtonError: Button click handler failed with code " .. math.random(1000, 9999)) + elseif category == "rendering" then + logger.error("Graphics rendering failure") + error("Love2DRenderError: Failed to render game object at frame " .. love.timer.getTime()) + else + logger.error("Generic game error occurred") + error("Love2DGameError: Unexpected game state error in category " .. tostring(category)) + end + end, + } + + -- Log successful initialization + logger.info("Love2D Sentry demo initialized successfully") + logger.info("Love2D version: %s", { table.concat({ love.getVersion() }, ".") }) + logger.info("Operating system: %s", { love.system.getOS() }) + + -- Add breadcrumb for debugging context + sentry.add_breadcrumb({ + message = "Love2D game initialized", + category = "game_lifecycle", + level = "info", + }) end function love.update(dt) - -- Get mouse position for button hover detection - local mouse_x, mouse_y = love.mouse.getPosition() - local button = game.button - - -- Check if mouse is over button - local was_hover = button.hover - button.hover = (mouse_x >= button.x and mouse_x <= button.x + button.width and - mouse_y >= button.y and mouse_y <= button.y + button.height) - - -- Log hover state changes - if button.hover and not was_hover then - logger.debug("Button hover state: entered") - elseif not button.hover and was_hover then - logger.debug("Button hover state: exited") - end - - -- Flush Sentry transport periodically - sentry.flush() - - -- Flush logger periodically - if math.floor(love.timer.getTime()) % 3 == 0 then - logger.flush() - end + -- Get mouse position for button hover detection + local mouse_x, mouse_y = love.mouse.getPosition() + local button = game.button + + -- Check if mouse is over button + local was_hover = button.hover + button.hover = (mouse_x >= button.x and mouse_x <= button.x + button.width and mouse_y >= button.y and mouse_y <= button.y + button.height) + + -- Log hover state changes + if button.hover and not was_hover then + logger.debug("Button hover state: entered") + elseif not button.hover and was_hover then + logger.debug("Button hover state: exited") + end + + -- Flush Sentry transport periodically + sentry.flush() + + -- Flush logger periodically + if math.floor(love.timer.getTime()) % 3 == 0 then logger.flush() end end function love.draw() - -- Clear screen with dark background - love.graphics.clear(0.1, 0.1, 0.15, 1.0) - - -- Draw title - love.graphics.setFont(game.font_large) - love.graphics.setColor(1, 1, 1, 1) - love.graphics.printf("Love2D Sentry Integration", 0, 50, love.graphics.getWidth(), "center") - - -- Draw Sentry logo (simple S shape) - love.graphics.setColor(0.4, 0.3, 0.8, 1) -- Purple color similar to Sentry - love.graphics.setLineWidth(8) - - -- Draw S shape - local logo_x, logo_y = 350, 120 - for i = 1, #game.logo_points - 1 do - local x1, y1 = game.logo_points[i][1] + logo_x, game.logo_points[i][2] + logo_y - local x2, y2 = game.logo_points[i + 1][1] + logo_x, game.logo_points[i + 1][2] + logo_y - love.graphics.line(x1, y1, x2, y2) - end - - -- Draw info text - love.graphics.setFont(game.font_small) - love.graphics.setColor(0.8, 0.8, 0.8, 1) - love.graphics.printf("This demo shows Love2D integration with Sentry SDK", 0, 280, love.graphics.getWidth(), "center") - love.graphics.printf("Red: Regular error (caught) • Purple: Fatal error (love.errorhandler)", 0, 300, love.graphics.getWidth(), "center") - love.graphics.printf("Press 'R' for regular error, 'F' for fatal error, 'ESC' to exit", 0, 320, love.graphics.getWidth(), "center") + -- Clear screen with dark background + love.graphics.clear(0.1, 0.1, 0.15, 1.0) + + -- Draw title + love.graphics.setFont(game.font_large) + love.graphics.setColor(1, 1, 1, 1) + love.graphics.printf("Love2D Sentry Integration", 0, 50, love.graphics.getWidth(), "center") + + -- Draw Sentry logo (simple S shape) + love.graphics.setColor(0.4, 0.3, 0.8, 1) -- Purple color similar to Sentry + love.graphics.setLineWidth(8) + + -- Draw S shape + local logo_x, logo_y = 350, 120 + for i = 1, #game.logo_points - 1 do + local x1, y1 = game.logo_points[i][1] + logo_x, game.logo_points[i][2] + logo_y + local x2, y2 = game.logo_points[i + 1][1] + logo_x, game.logo_points[i + 1][2] + logo_y + love.graphics.line(x1, y1, x2, y2) + end + + -- Draw info text + love.graphics.setFont(game.font_small) + love.graphics.setColor(0.8, 0.8, 0.8, 1) + love.graphics.printf("This demo shows Love2D integration with Sentry SDK", 0, 280, love.graphics.getWidth(), "center") + love.graphics.printf("Red: Regular error (caught) • Purple: Fatal error (love.errorhandler)", 0, 300, love.graphics.getWidth(), "center") + love.graphics.printf("Press 'R' for regular error, 'F' for fatal error, 'ESC' to exit", 0, 320, love.graphics.getWidth(), "center") + + -- Draw button + local button = game.button + local button_color = button.hover and { 0.8, 0.2, 0.2, 1 } or { 0.6, 0.1, 0.1, 1 } + if button.pressed then button_color = { 1.0, 0.3, 0.3, 1 } end + + love.graphics.setColor(button_color[1], button_color[2], button_color[3], button_color[4]) + love.graphics.rectangle("fill", button.x, button.y, button.width, button.height, 8, 8) + + -- Draw button border + love.graphics.setColor(1, 1, 1, 0.3) + love.graphics.setLineWidth(2) + love.graphics.rectangle("line", button.x, button.y, button.width, button.height, 8, 8) + + -- Draw button text + love.graphics.setFont(game.button_font) + love.graphics.setColor(1, 1, 1, 1) + local text_width = game.button_font:getWidth(button.text) + local text_height = game.button_font:getHeight() + love.graphics.print(button.text, button.x + (button.width - text_width) / 2, button.y + (button.height - text_height) / 2) + + -- Draw fatal button + local fatal_button = game.fatal_button + local fatal_button_color = fatal_button.hover and { 0.8, 0.2, 0.8, 1 } or { 0.6, 0.1, 0.6, 1 } + if fatal_button.pressed then fatal_button_color = { 1.0, 0.3, 1.0, 1 } end + + love.graphics.setColor(fatal_button_color[1], fatal_button_color[2], fatal_button_color[3], fatal_button_color[4]) + love.graphics.rectangle("fill", fatal_button.x, fatal_button.y, fatal_button.width, fatal_button.height, 8, 8) + + -- Draw fatal button border + love.graphics.setColor(1, 1, 1, 0.3) + love.graphics.setLineWidth(2) + love.graphics.rectangle("line", fatal_button.x, fatal_button.y, fatal_button.width, fatal_button.height, 8, 8) + + -- Draw fatal button text + love.graphics.setFont(game.button_font) + love.graphics.setColor(1, 1, 1, 1) + local fatal_text_width = game.button_font:getWidth(fatal_button.text) + love.graphics.print(fatal_button.text, fatal_button.x + (fatal_button.width - fatal_text_width) / 2, fatal_button.y + (fatal_button.height - text_height) / 2) + + -- Draw stats + love.graphics.setFont(game.font_small) + love.graphics.setColor(0.7, 0.7, 0.7, 1) + love.graphics.print(string.format("Errors triggered: %d", game.error_count), 20, love.graphics.getHeight() - 60) + love.graphics.print(string.format("Framework: Love2D %s", table.concat({ love.getVersion() }, ".")), 20, love.graphics.getHeight() - 40) + love.graphics.print(string.format("Platform: %s", love.system.getOS()), 20, love.graphics.getHeight() - 20) + + if game.last_error_time > 0 then love.graphics.print(string.format("Last error: %.1fs ago", love.timer.getTime() - game.last_error_time), 400, love.graphics.getHeight() - 40) end +end - -- Draw button +function love.mousepressed(x, y, button_num, istouch, presses) + if button_num == 1 then -- Left mouse button local button = game.button - local button_color = button.hover and {0.8, 0.2, 0.2, 1} or {0.6, 0.1, 0.1, 1} - if button.pressed then - button_color = {1.0, 0.3, 0.3, 1} - end - love.graphics.setColor(button_color[1], button_color[2], button_color[3], button_color[4]) - love.graphics.rectangle("fill", button.x, button.y, button.width, button.height, 8, 8) - - -- Draw button border - love.graphics.setColor(1, 1, 1, 0.3) - love.graphics.setLineWidth(2) - love.graphics.rectangle("line", button.x, button.y, button.width, button.height, 8, 8) - - -- Draw button text - love.graphics.setFont(game.button_font) - love.graphics.setColor(1, 1, 1, 1) - local text_width = game.button_font:getWidth(button.text) - local text_height = game.button_font:getHeight() - love.graphics.print(button.text, - button.x + (button.width - text_width) / 2, - button.y + (button.height - text_height) / 2) - - -- Draw fatal button - local fatal_button = game.fatal_button - local fatal_button_color = fatal_button.hover and {0.8, 0.2, 0.8, 1} or {0.6, 0.1, 0.6, 1} - if fatal_button.pressed then - fatal_button_color = {1.0, 0.3, 1.0, 1} - end - - love.graphics.setColor(fatal_button_color[1], fatal_button_color[2], fatal_button_color[3], fatal_button_color[4]) - love.graphics.rectangle("fill", fatal_button.x, fatal_button.y, fatal_button.width, fatal_button.height, 8, 8) - - -- Draw fatal button border - love.graphics.setColor(1, 1, 1, 0.3) - love.graphics.setLineWidth(2) - love.graphics.rectangle("line", fatal_button.x, fatal_button.y, fatal_button.width, fatal_button.height, 8, 8) - - -- Draw fatal button text - love.graphics.setFont(game.button_font) - love.graphics.setColor(1, 1, 1, 1) - local fatal_text_width = game.button_font:getWidth(fatal_button.text) - love.graphics.print(fatal_button.text, - fatal_button.x + (fatal_button.width - fatal_text_width) / 2, - fatal_button.y + (fatal_button.height - text_height) / 2) - - -- Draw stats - love.graphics.setFont(game.font_small) - love.graphics.setColor(0.7, 0.7, 0.7, 1) - love.graphics.print(string.format("Errors triggered: %d", game.error_count), 20, love.graphics.getHeight() - 60) - love.graphics.print(string.format("Framework: Love2D %s", table.concat({love.getVersion()}, ".")), 20, love.graphics.getHeight() - 40) - love.graphics.print(string.format("Platform: %s", love.system.getOS()), 20, love.graphics.getHeight() - 20) - - if game.last_error_time > 0 then - love.graphics.print(string.format("Last error: %.1fs ago", love.timer.getTime() - game.last_error_time), 400, love.graphics.getHeight() - 40) - end -end + -- Check if click is within button bounds + if x >= button.x and x <= button.x + button.width and y >= button.y and y <= button.y + button.height then + button.pressed = true + + -- Add breadcrumb before triggering error + sentry.add_breadcrumb({ + message = "Error button clicked", + category = "user_interaction", + level = "info", + data = { + mouse_x = x, + mouse_y = y, + error_count = game.error_count + 1, + }, + }) + + -- Log the button click + logger.info("Error button clicked at position (%s, %s)", { x, y }) + logger.info("Preparing to trigger multi-frame error...") + + -- Use xpcall to capture the error with original stack trace + local function error_handler(err) + logger.error("Button click error occurred: %s", { tostring(err) }) + + sentry.capture_exception({ + type = "Love2DUserTriggeredError", + message = tostring(err), + }) -function love.mousepressed(x, y, button_num, istouch, presses) - if button_num == 1 then -- Left mouse button - local button = game.button - - -- Check if click is within button bounds - if x >= button.x and x <= button.x + button.width and - y >= button.y and y <= button.y + button.height then - - button.pressed = true - - -- Add breadcrumb before triggering error - sentry.add_breadcrumb({ - message = "Error button clicked", - category = "user_interaction", - level = "info", - data = { - mouse_x = x, - mouse_y = y, - error_count = game.error_count + 1 - } - }) - - -- Log the button click - logger.info("Error button clicked at position (%s, %s)", {x, y}) - logger.info("Preparing to trigger multi-frame error...") - - -- Use xpcall to capture the error with original stack trace - local function error_handler(err) - logger.error("Button click error occurred: %s", {tostring(err)}) - - sentry.capture_exception({ - type = "Love2DUserTriggeredError", - message = tostring(err) - }) - - logger.info("Error captured and sent to Sentry") - return err - end - - -- Trigger error through multi-level function calls - xpcall(function() - game.demo_functions.level1("button_click", "button_click") - end, error_handler) - - -- Check if click is within fatal button bounds - elseif x >= game.fatal_button.x and x <= game.fatal_button.x + game.fatal_button.width and - y >= game.fatal_button.y and y <= game.fatal_button.y + game.fatal_button.height then - - game.fatal_button.pressed = true - - -- Add breadcrumb before triggering fatal error - sentry.add_breadcrumb({ - message = "Fatal error button clicked - will trigger love.errorhandler", - category = "user_interaction", - level = "warning", - data = { - mouse_x = x, - mouse_y = y, - test_type = "fatal_error" - } - }) - - -- Log the fatal button click - logger.info("Fatal error button clicked at position (%s, %s)", {x, y}) - logger.info("This will trigger love.errorhandler and crash the app...") - - -- Trigger a fatal error that will go through love.errorhandler - -- This error is NOT caught with xpcall, so it will bubble up to love.errorhandler - error("Fatal Love2D error triggered by user - Testing love.errorhandler integration!") - end + logger.info("Error captured and sent to Sentry") + return err + end + + -- Trigger error through multi-level function calls + xpcall(function() game.demo_functions.level1("button_click", "button_click") end, error_handler) + + -- Check if click is within fatal button bounds + elseif x >= game.fatal_button.x and x <= game.fatal_button.x + game.fatal_button.width and y >= game.fatal_button.y and y <= game.fatal_button.y + game.fatal_button.height then + game.fatal_button.pressed = true + + -- Add breadcrumb before triggering fatal error + sentry.add_breadcrumb({ + message = "Fatal error button clicked - will trigger love.errorhandler", + category = "user_interaction", + level = "warning", + data = { + mouse_x = x, + mouse_y = y, + test_type = "fatal_error", + }, + }) + + -- Log the fatal button click + logger.info("Fatal error button clicked at position (%s, %s)", { x, y }) + logger.info("This will trigger love.errorhandler and crash the app...") + + -- Trigger a fatal error that will go through love.errorhandler + -- This error is NOT caught with xpcall, so it will bubble up to love.errorhandler + error("Fatal Love2D error triggered by user - Testing love.errorhandler integration!") end + end end function love.mousereleased(x, y, button_num, istouch, presses) - if button_num == 1 then - game.button.pressed = false - game.fatal_button.pressed = false - end + if button_num == 1 then + game.button.pressed = false + game.fatal_button.pressed = false + end end function love.keypressed(key) - if key == "escape" then - -- Clean shutdown with Sentry flush - logger.info("Application shutting down") - logger.flush() - - sentry.close() - - love.event.quit() - elseif key == "r" then - -- Trigger rendering error - logger.info("Rendering error triggered via keyboard") - - sentry.add_breadcrumb({ - message = "Rendering error triggered", - category = "keyboard_interaction", - level = "info" - }) + if key == "escape" then + -- Clean shutdown with Sentry flush + logger.info("Application shutting down") + logger.flush() - local function error_handler(err) - sentry.capture_exception({ - type = "Love2DRenderingError", - message = tostring(err) - }) - return err - end - - xpcall(function() - game.demo_functions.level1("render_test", "rendering") - end, error_handler) - - elseif key == "f" then - -- Trigger fatal error via keyboard - logger.info("Fatal error triggered via keyboard - will crash app") - - sentry.add_breadcrumb({ - message = "Fatal error triggered via keyboard (F key)", - category = "keyboard_interaction", - level = "warning", - data = { - test_type = "fatal_error_keyboard" - } - }) + sentry.close() + + love.event.quit() + elseif key == "r" then + -- Trigger rendering error + logger.info("Rendering error triggered via keyboard") - -- This will go through love.errorhandler and crash the app - error("Fatal Love2D error triggered by keyboard (F key) - Testing love.errorhandler integration!") + sentry.add_breadcrumb({ + message = "Rendering error triggered", + category = "keyboard_interaction", + level = "info", + }) + + local function error_handler(err) + sentry.capture_exception({ + type = "Love2DRenderingError", + message = tostring(err), + }) + return err end + + xpcall(function() game.demo_functions.level1("render_test", "rendering") end, error_handler) + elseif key == "f" then + -- Trigger fatal error via keyboard + logger.info("Fatal error triggered via keyboard - will crash app") + + sentry.add_breadcrumb({ + message = "Fatal error triggered via keyboard (F key)", + category = "keyboard_interaction", + level = "warning", + data = { + test_type = "fatal_error_keyboard", + }, + }) + + -- This will go through love.errorhandler and crash the app + error("Fatal Love2D error triggered by keyboard (F key) - Testing love.errorhandler integration!") + end end function love.quit() - -- Clean shutdown - logger.info("Love2D application quit") - logger.flush() + -- Clean shutdown + logger.info("Love2D application quit") + logger.flush() - sentry.close() + sentry.close() - return false -- Allow quit to proceed -end \ No newline at end of file + return false -- Allow quit to proceed +end diff --git a/examples/wrap_demo.lua b/examples/wrap_demo.lua index 3873088..0253fbc 100644 --- a/examples/wrap_demo.lua +++ b/examples/wrap_demo.lua @@ -6,72 +6,72 @@ local sentry = require("sentry.init") -- Initialize Sentry sentry.init({ - dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", - environment = "demo", - release = "wrap-demo@1.0", - debug = true + dsn = "https://e247e6e48f8f482499052a65adaa9f6b@o117736.ingest.us.sentry.io/4504930623356928", + environment = "demo", + release = "wrap-demo@1.0", + debug = true, }) -- Set up context that will be included with any captured errors sentry.set_user({ - id = "wrap-demo-user", - username = "error_demo" + id = "wrap-demo-user", + username = "error_demo", }) sentry.set_tag("demo_type", "wrap_function") -- Define your main application logic with parameters for stack trace visibility local function process_database_config(environment, service_name, retry_count) - local config = nil -- Simulate missing configuration - local timeout_ms = 5000 - local connection_pool_size = 10 + local config = nil -- Simulate missing configuration + local timeout_ms = 5000 + local connection_pool_size = 10 - -- This will cause an error that gets automatically captured - local db_url = config.database_url -- Error: attempt to index nil - return db_url .. "?timeout=" .. timeout_ms .. "&pool=" .. connection_pool_size + -- This will cause an error that gets automatically captured + local db_url = config.database_url -- Error: attempt to index nil + return db_url .. "?timeout=" .. timeout_ms .. "&pool=" .. connection_pool_size end local function validate_user_permissions(user_id, action_type, resource_id) - -- Process permissions validation - return process_database_config("production", "user_service", 3) + -- Process permissions validation + return process_database_config("production", "user_service", 3) end local function main(app_version, startup_mode) - print("=== Sentry.wrap() Demo === (v" .. app_version .. ", mode: " .. startup_mode .. ")") - -- Add some context - sentry.add_breadcrumb({ - message = "Application started", - category = "lifecycle", - level = "info" - }) - - print("1. Setting up user data...") - local users = {"alice", "bob", "charlie"} - - print("2. Processing payments...") - sentry.add_breadcrumb({ - message = "Starting payment processing", - category = "business", - level = "info" - }) - - -- Simulate some successful operations - for i, user in ipairs(users) do - local amount = 100 * i - print(" Processing payment for:", user, "($" .. amount .. ")") - sentry.add_breadcrumb({ - message = "Payment processed", - category = "payment", - level = "info", - data = { user = user, amount = amount } - }) - end - - print("3. Validating user permissions...") - -- This will ultimately cause an error through the call chain - local result = validate_user_permissions("user_12345", "database_write", "config_table") - - print("This line should never be reached:", result) + print("=== Sentry.wrap() Demo === (v" .. app_version .. ", mode: " .. startup_mode .. ")") + -- Add some context + sentry.add_breadcrumb({ + message = "Application started", + category = "lifecycle", + level = "info", + }) + + print("1. Setting up user data...") + local users = { "alice", "bob", "charlie" } + + print("2. Processing payments...") + sentry.add_breadcrumb({ + message = "Starting payment processing", + category = "business", + level = "info", + }) + + -- Simulate some successful operations + for i, user in ipairs(users) do + local amount = 100 * i + print(" Processing payment for:", user, "($" .. amount .. ")") + sentry.add_breadcrumb({ + message = "Payment processed", + category = "payment", + level = "info", + data = { user = user, amount = amount }, + }) + end + + print("3. Validating user permissions...") + -- This will ultimately cause an error through the call chain + local result = validate_user_permissions("user_12345", "database_write", "config_table") + + print("This line should never be reached:", result) end -- Method 1: Simple wrap - Errors terminate the program but get sent to Sentry @@ -79,65 +79,61 @@ print("\n=== Method 1: Simple Wrap ===") local success, result = sentry.wrap(function() main("2.1.4", "production") end) if success then - print("✓ Program completed successfully") + print("✓ Program completed successfully") else - print("✗ Program failed but error was captured in Sentry") - print("Error:", result) + print("✗ Program failed but error was captured in Sentry") + print("Error:", result) end print("\n=== Method 2: Custom Error Handler ===") -- Method 2: Custom error handler - You can handle errors gracefully local function attempt_risky_operation(operation_id, max_retries, timeout_seconds) - local cache_key = "op_" .. operation_id - print("Attempting risky operation (ID: " .. operation_id .. ", retries: " .. max_retries .. ")...") + local cache_key = "op_" .. operation_id + print("Attempting risky operation (ID: " .. operation_id .. ", retries: " .. max_retries .. ")...") - local risky_data = nil - return risky_data.missing_field .. " cached as " .. cache_key -- This will error + local risky_data = nil + return risky_data.missing_field .. " cached as " .. cache_key -- This will error end -local function main_with_recovery() - return attempt_risky_operation("op_789", 5, 30) -end +local function main_with_recovery() return attempt_risky_operation("op_789", 5, 30) end local function custom_error_handler(err) - local error_id = "err_" .. math.random(10000, 99999) - local timestamp = os.date("%Y-%m-%d %H:%M:%S") + local error_id = "err_" .. math.random(10000, 99999) + local timestamp = os.date("%Y-%m-%d %H:%M:%S") - print("Custom handler: Caught error [" .. error_id .. " at " .. timestamp .. "]:", err) - print("Custom handler: Performing cleanup...") - -- You could do cleanup, logging, etc. here - return "Handled gracefully (error ID: " .. error_id .. ")" + print("Custom handler: Caught error [" .. error_id .. " at " .. timestamp .. "]:", err) + print("Custom handler: Performing cleanup...") + -- You could do cleanup, logging, etc. here + return "Handled gracefully (error ID: " .. error_id .. ")" end local success2, result2 = sentry.wrap(main_with_recovery, custom_error_handler) if success2 then - print("✓ Program handled error gracefully") - print("Result:", result2) + print("✓ Program handled error gracefully") + print("Result:", result2) else - print("✗ Even custom handler couldn't save us") - print("Error:", result2) + print("✗ Even custom handler couldn't save us") + print("Error:", result2) end print("\n=== Comparison with Manual Approach ===") -- Show equivalent manual approach for comparison local function manual_error_simulation(task_name, priority_level) - local task_id = "task_" .. math.random(100, 999) - error("Manual error handling for " .. task_name .. " (priority: " .. priority_level .. ", task: " .. task_id .. ")") + local task_id = "task_" .. math.random(100, 999) + error("Manual error handling for " .. task_name .. " (priority: " .. priority_level .. ", task: " .. task_id .. ")") end local function manual_approach() - return xpcall(function() - manual_error_simulation("data_processing", "high") - end, function(err) - sentry.capture_exception({ - type = "ManualError", - message = tostring(err) - }) - return err - end) + return xpcall(function() manual_error_simulation("data_processing", "high") end, function(err) + sentry.capture_exception({ + type = "ManualError", + message = tostring(err), + }) + return err + end) end local manual_success, manual_result = manual_approach() @@ -149,4 +145,4 @@ print("\n=== Summary ===") print("• sentry.wrap(main_function) - Simple automatic error capture") print("• sentry.wrap(main_function, error_handler) - Custom error handling") print("• All Sentry context (user, tags, breadcrumbs) is automatically included") -print("• Much simpler than manually wrapping every error-prone operation") \ No newline at end of file +print("• Much simpler than manually wrapping every error-prone operation") diff --git a/spec/dsn_parsing_spec.lua b/spec/dsn_parsing_spec.lua index a9e97ce..8f577ef 100644 --- a/spec/dsn_parsing_spec.lua +++ b/spec/dsn_parsing_spec.lua @@ -1,243 +1,241 @@ describe("DSN Parsing", function() - local dsn_utils - - before_each(function() - dsn_utils = require("sentry.core.dsn") - end) - - describe("Valid DSN parsing", function() - it("should parse a standard valid DSN with secret", function() - local dsn_string = "https://public_key:secret_key@sentry.io/123456" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("https", dsn.protocol) - assert.are.equal("public_key", dsn.public_key) - assert.are.equal("secret_key", dsn.secret_key) - assert.are.equal("sentry.io", dsn.host) - assert.are.equal(443, dsn.port) - assert.are.equal("/123456", dsn.path) - assert.are.equal("123456", dsn.project_id) - end) - - it("should parse a valid DSN without secret key", function() - local dsn_string = "https://public_key@sentry.io/789" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("https", dsn.protocol) - assert.are.equal("public_key", dsn.public_key) - assert.are.equal("", dsn.secret_key) - assert.are.equal("sentry.io", dsn.host) - assert.are.equal("789", dsn.project_id) - end) - - it("should parse HTTP DSN with default port 80", function() - local dsn_string = "http://public_key@localhost/456" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("http", dsn.protocol) - assert.are.equal("localhost", dsn.host) - assert.are.equal(80, dsn.port) - assert.are.equal("456", dsn.project_id) - end) - - it("should parse DSN with custom port", function() - local dsn_string = "https://public_key@sentry.example.com:9000/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("sentry.example.com", dsn.host) - assert.are.equal(9000, dsn.port) - end) - - it("should parse DSN with path prefix", function() - local dsn_string = "https://public_key@sentry.io/path/to/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("/path/to/123", dsn.path) - assert.are.equal("123", dsn.project_id) - end) - - it("should parse DSN with subdomain", function() - local dsn_string = "https://abc123@org.ingest.sentry.io/456789" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("abc123", dsn.public_key) - assert.are.equal("org.ingest.sentry.io", dsn.host) - assert.are.equal("456789", dsn.project_id) - end) - - it("should handle numeric project IDs correctly", function() - local dsn_string = "https://key@host.com/1234567890" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("1234567890", dsn.project_id) - end) - end) - - describe("Invalid DSN parsing", function() - it("should reject nil DSN", function() - local dsn, error = dsn_utils.parse_dsn(nil) - - assert.is_nil(dsn) - assert.is_not_nil(error) - assert.are.equal("DSN is required", error) - end) - - it("should reject empty string DSN", function() - local dsn, error = dsn_utils.parse_dsn("") - - assert.is_nil(dsn) - assert.is_not_nil(error) - assert.are.equal("DSN is required", error) - end) - - it("should reject DSN without protocol", function() - local dsn, error = dsn_utils.parse_dsn("public_key@sentry.io/123") - - assert.is_nil(dsn) - assert.is_not_nil(error) - assert.are.equal("Invalid DSN format", error) - end) - - it("should reject DSN without public key", function() - local dsn, error = dsn_utils.parse_dsn("https://@sentry.io/123") - - assert.is_nil(dsn) - assert.is_not_nil(error) - assert.are.equal("Invalid DSN format", error) - end) - - it("should reject DSN without host", function() - local dsn, error = dsn_utils.parse_dsn("https://public_key@/123") - - assert.is_nil(dsn) - assert.is_not_nil(error) - assert.are.equal("Invalid DSN format", error) - end) - - it("should reject DSN without path", function() - local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io") - - assert.is_nil(dsn) - assert.is_not_nil(error) - assert.are.equal("Invalid DSN format", error) - end) - - it("should reject DSN without project ID", function() - local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/") - - assert.is_nil(dsn) - assert.is_not_nil(error) - assert.are.equal("Could not extract project ID from DSN", error) - end) - - it("should reject DSN with non-numeric project ID", function() - local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/project") - - assert.is_nil(dsn) - assert.is_not_nil(error) - assert.are.equal("Could not extract project ID from DSN", error) - end) - - it("should reject malformed URLs", function() - local malformed_dsns = { - "not-a-url", - "://missing-scheme", - "https://", - "https:///path/only", - "ftp://wrong-scheme@host.com/123", - } - - for _, dsn_string in ipairs(malformed_dsns) do - local dsn, error = dsn_utils.parse_dsn(dsn_string) - assert.is_nil(dsn) - assert.is_not_nil(error, "Expected error for DSN: " .. dsn_string) - end - end) - - it("should handle invalid port gracefully", function() - -- This tests the port parsing - invalid ports should fallback to default - local dsn_string = "https://key@host.com:invalid/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal(443, dsn.port) -- Should fallback to default HTTPS port - end) - end) - - describe("Auth header building", function() - it("should build auth header with secret key", function() - local dsn = { - public_key = "public123", - secret_key = "secret456" - } - - local header = dsn_utils.build_auth_header(dsn) - - -- Check that all required parts are present (version-agnostic) - assert.is_true(header:find("Sentry sentry_version=7") ~= nil) - assert.is_true(header:find("sentry_key=public123") ~= nil) - assert.is_true(header:find("sentry_secret=secret456") ~= nil) - assert.is_true(header:find("sentry_client=sentry%-lua/") ~= nil) -- Version-agnostic check - end) - - it("should build auth header without secret key", function() - local dsn = { - public_key = "public123", - secret_key = "" - } - - local header = dsn_utils.build_auth_header(dsn) - - -- Should not include secret - assert.is_true(header:find("sentry_key=public123") ~= nil) - assert.is_true(header:find("sentry_secret") == nil) - end) - - it("should build auth header with nil secret key", function() - local dsn = { - public_key = "public123", - secret_key = nil - } - - local header = dsn_utils.build_auth_header(dsn) - - -- Should not include secret - assert.is_true(header:find("sentry_key=public123") ~= nil) - assert.is_true(header:find("sentry_secret") == nil) - end) - end) - - describe("Edge cases and security", function() - it("should handle special characters in keys", function() - local dsn_string = "https://key-with.dots_and-dashes@host.com/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("key-with.dots_and-dashes", dsn.public_key) - end) - - it("should handle IPv4 addresses", function() - local dsn_string = "https://key@192.168.1.1/123" - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal("192.168.1.1", dsn.host) - end) - - it("should handle very long project IDs", function() - local long_id = "123456789012345678901234567890" - local dsn_string = "https://key@host.com/" .. long_id - local dsn, error = dsn_utils.parse_dsn(dsn_string) - - assert.is_nil(error or (error ~= "" and error or nil)) - assert.are.equal(long_id, dsn.project_id) - end) - end) -end) \ No newline at end of file + local dsn_utils + + before_each(function() dsn_utils = require("sentry.core.dsn") end) + + describe("Valid DSN parsing", function() + it("should parse a standard valid DSN with secret", function() + local dsn_string = "https://public_key:secret_key@sentry.io/123456" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("https", dsn.protocol) + assert.are.equal("public_key", dsn.public_key) + assert.are.equal("secret_key", dsn.secret_key) + assert.are.equal("sentry.io", dsn.host) + assert.are.equal(443, dsn.port) + assert.are.equal("/123456", dsn.path) + assert.are.equal("123456", dsn.project_id) + end) + + it("should parse a valid DSN without secret key", function() + local dsn_string = "https://public_key@sentry.io/789" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("https", dsn.protocol) + assert.are.equal("public_key", dsn.public_key) + assert.are.equal("", dsn.secret_key) + assert.are.equal("sentry.io", dsn.host) + assert.are.equal("789", dsn.project_id) + end) + + it("should parse HTTP DSN with default port 80", function() + local dsn_string = "http://public_key@localhost/456" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("http", dsn.protocol) + assert.are.equal("localhost", dsn.host) + assert.are.equal(80, dsn.port) + assert.are.equal("456", dsn.project_id) + end) + + it("should parse DSN with custom port", function() + local dsn_string = "https://public_key@sentry.example.com:9000/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("sentry.example.com", dsn.host) + assert.are.equal(9000, dsn.port) + end) + + it("should parse DSN with path prefix", function() + local dsn_string = "https://public_key@sentry.io/path/to/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("/path/to/123", dsn.path) + assert.are.equal("123", dsn.project_id) + end) + + it("should parse DSN with subdomain", function() + local dsn_string = "https://abc123@org.ingest.sentry.io/456789" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("abc123", dsn.public_key) + assert.are.equal("org.ingest.sentry.io", dsn.host) + assert.are.equal("456789", dsn.project_id) + end) + + it("should handle numeric project IDs correctly", function() + local dsn_string = "https://key@host.com/1234567890" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("1234567890", dsn.project_id) + end) + end) + + describe("Invalid DSN parsing", function() + it("should reject nil DSN", function() + local dsn, error = dsn_utils.parse_dsn(nil) + + assert.is_nil(dsn) + assert.is_not_nil(error) + assert.are.equal("DSN is required", error) + end) + + it("should reject empty string DSN", function() + local dsn, error = dsn_utils.parse_dsn("") + + assert.is_nil(dsn) + assert.is_not_nil(error) + assert.are.equal("DSN is required", error) + end) + + it("should reject DSN without protocol", function() + local dsn, error = dsn_utils.parse_dsn("public_key@sentry.io/123") + + assert.is_nil(dsn) + assert.is_not_nil(error) + assert.are.equal("Invalid DSN format", error) + end) + + it("should reject DSN without public key", function() + local dsn, error = dsn_utils.parse_dsn("https://@sentry.io/123") + + assert.is_nil(dsn) + assert.is_not_nil(error) + assert.are.equal("Invalid DSN format", error) + end) + + it("should reject DSN without host", function() + local dsn, error = dsn_utils.parse_dsn("https://public_key@/123") + + assert.is_nil(dsn) + assert.is_not_nil(error) + assert.are.equal("Invalid DSN format", error) + end) + + it("should reject DSN without path", function() + local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io") + + assert.is_nil(dsn) + assert.is_not_nil(error) + assert.are.equal("Invalid DSN format", error) + end) + + it("should reject DSN without project ID", function() + local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/") + + assert.is_nil(dsn) + assert.is_not_nil(error) + assert.are.equal("Could not extract project ID from DSN", error) + end) + + it("should reject DSN with non-numeric project ID", function() + local dsn, error = dsn_utils.parse_dsn("https://public_key@sentry.io/project") + + assert.is_nil(dsn) + assert.is_not_nil(error) + assert.are.equal("Could not extract project ID from DSN", error) + end) + + it("should reject malformed URLs", function() + local malformed_dsns = { + "not-a-url", + "://missing-scheme", + "https://", + "https:///path/only", + "ftp://wrong-scheme@host.com/123", + } + + for _, dsn_string in ipairs(malformed_dsns) do + local dsn, error = dsn_utils.parse_dsn(dsn_string) + assert.is_nil(dsn) + assert.is_not_nil(error, "Expected error for DSN: " .. dsn_string) + end + end) + + it("should handle invalid port gracefully", function() + -- This tests the port parsing - invalid ports should fallback to default + local dsn_string = "https://key@host.com:invalid/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal(443, dsn.port) -- Should fallback to default HTTPS port + end) + end) + + describe("Auth header building", function() + it("should build auth header with secret key", function() + local dsn = { + public_key = "public123", + secret_key = "secret456", + } + + local header = dsn_utils.build_auth_header(dsn) + + -- Check that all required parts are present (version-agnostic) + assert.is_true(header:find("Sentry sentry_version=7") ~= nil) + assert.is_true(header:find("sentry_key=public123") ~= nil) + assert.is_true(header:find("sentry_secret=secret456") ~= nil) + assert.is_true(header:find("sentry_client=sentry%-lua/") ~= nil) -- Version-agnostic check + end) + + it("should build auth header without secret key", function() + local dsn = { + public_key = "public123", + secret_key = "", + } + + local header = dsn_utils.build_auth_header(dsn) + + -- Should not include secret + assert.is_true(header:find("sentry_key=public123") ~= nil) + assert.is_true(header:find("sentry_secret") == nil) + end) + + it("should build auth header with nil secret key", function() + local dsn = { + public_key = "public123", + secret_key = nil, + } + + local header = dsn_utils.build_auth_header(dsn) + + -- Should not include secret + assert.is_true(header:find("sentry_key=public123") ~= nil) + assert.is_true(header:find("sentry_secret") == nil) + end) + end) + + describe("Edge cases and security", function() + it("should handle special characters in keys", function() + local dsn_string = "https://key-with.dots_and-dashes@host.com/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("key-with.dots_and-dashes", dsn.public_key) + end) + + it("should handle IPv4 addresses", function() + local dsn_string = "https://key@192.168.1.1/123" + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal("192.168.1.1", dsn.host) + end) + + it("should handle very long project IDs", function() + local long_id = "123456789012345678901234567890" + local dsn_string = "https://key@host.com/" .. long_id + local dsn, error = dsn_utils.parse_dsn(dsn_string) + + assert.is_nil(error or (error ~= "" and error or nil)) + assert.are.equal(long_id, dsn.project_id) + end) + end) +end) diff --git a/src/sentry/core/client.lua b/src/sentry/core/client.lua index b1b3cbc..972f2a3 100644 --- a/src/sentry/core/client.lua +++ b/src/sentry/core/client.lua @@ -4,33 +4,33 @@ local Client = {} Client.__index = Client function Client:new(options) - if not options then - diagnostic_logger.warn("Cannot create a Sentry client without options.") - return nil - end - if not options.dsn then - diagnostic_logger.warn("Cannot create a Sentry client without DSN.") - return nil - end - local client = setmetatable({ - options = options, - transport = Transport:new(options.dsn, options.transport_options), - }, {__index = self}) - return client + if not options then + diagnostic_logger.warn("Cannot create a Sentry client without options.") + return nil + end + if not options.dsn then + diagnostic_logger.warn("Cannot create a Sentry client without DSN.") + return nil + end + local client = setmetatable({ + options = options, + transport = Transport:new(options.dsn, options.transport_options), + }, { __index = self }) + return client end function Client:capture_message(message) - if not self.transport then - diagnostic_logger.error("Transport not initialized") - return nil - end + if not self.transport then + diagnostic_logger.error("Transport not initialized") + return nil + end - local event = { - message = message, - level = "info", - } + local event = { + message = message, + level = "info", + } - return self.transport:send_event(event) + return self.transport:send_event(event) end -return Client \ No newline at end of file +return Client diff --git a/src/sentry/core/diagnostic_logger.lua b/src/sentry/core/diagnostic_logger.lua index 35b6a09..7547abf 100644 --- a/src/sentry/core/diagnostic_logger.lua +++ b/src/sentry/core/diagnostic_logger.lua @@ -1,16 +1,12 @@ local diagnostic_logger = {} -diagnostic_logger.debug = function(message) - print("[Sentry] " .. message) -end +diagnostic_logger.debug = function(message) print("[Sentry] " .. message) end -diagnostic_logger.warn = function(message) - warn("[Sentry] " .. message) -end +diagnostic_logger.warn = function(message) warn("[Sentry] " .. message) end diagnostic_logger.error = function(message) - warn("[Sentry] ERROR: " .. message, 2) - -- error("[Sentry] " .. message, 2) + warn("[Sentry] ERROR: " .. message, 2) + -- error("[Sentry] " .. message, 2) end -return diagnostic_logger \ No newline at end of file +return diagnostic_logger diff --git a/src/sentry/core/dsn.lua b/src/sentry/core/dsn.lua index ed12f27..1820fa1 100644 --- a/src/sentry/core/dsn.lua +++ b/src/sentry/core/dsn.lua @@ -1,90 +1,69 @@ local version = require("core.version") local function parse_dsn(dsn_string) - if not dsn_string or dsn_string == "" then - return nil, "DSN is required" - end + if not dsn_string or dsn_string == "" then return nil, "DSN is required" end - -- Pattern to match DSN with optional secret key - -- https://public_key:secret_key@host:port/path/project_id - -- https://public_key@host:port/path/project_id - local protocol, credentials, host_path = dsn_string:match("^(https?)://([^@]+)@(.+)$") + -- Pattern to match DSN with optional secret key + -- https://public_key:secret_key@host:port/path/project_id + -- https://public_key@host:port/path/project_id + local protocol, credentials, host_path = dsn_string:match("^(https?)://([^@]+)@(.+)$") - if not protocol or not credentials or not host_path then - return {}, "Invalid DSN format" - end + if not protocol or not credentials or not host_path then return {}, "Invalid DSN format" end - -- Parse credentials (public_key or public_key:secret_key) - local public_key, secret_key = credentials:match("^([^:]+):(.+)$") - if not public_key then - public_key = credentials - secret_key = "" - end + -- Parse credentials (public_key or public_key:secret_key) + local public_key, secret_key = credentials:match("^([^:]+):(.+)$") + if not public_key then + public_key = credentials + secret_key = "" + end - if not public_key or public_key == "" then - return {}, "Invalid DSN format" - end + if not public_key or public_key == "" then return {}, "Invalid DSN format" end - -- Parse host and path - local host, path = host_path:match("^([^/]+)(.*)$") - if not host or not path or path == "" then - return {}, "Invalid DSN format" - end + -- Parse host and path + local host, path = host_path:match("^([^/]+)(.*)$") + if not host or not path or path == "" then return {}, "Invalid DSN format" end - -- Extract project ID from path (last numeric segment) - local project_id = path:match("/([%d]+)$") - if not project_id then - return {}, "Could not extract project ID from DSN" - end + -- Extract project ID from path (last numeric segment) + local project_id = path:match("/([%d]+)$") + if not project_id then return {}, "Could not extract project ID from DSN" end - -- Parse port from host - local port = 443 - if protocol == "http" then - port = 80 - end + -- Parse port from host + local port = 443 + if protocol == "http" then port = 80 end - local host_part, port_part = host:match("^([^:]+):?(%d*)$") - if host_part then - host = host_part - if port_part and port_part ~= "" then - port = tonumber(port_part) or port - end - end + local host_part, port_part = host:match("^([^:]+):?(%d*)$") + if host_part then + host = host_part + if port_part and port_part ~= "" then port = tonumber(port_part) or port end + end - return { - protocol = protocol, - public_key = public_key, - secret_key = secret_key or "", - host = host, - port = port, - path = path, - project_id = project_id - }, nil + return { + protocol = protocol, + public_key = public_key, + secret_key = secret_key or "", + host = host, + port = port, + path = path, + project_id = project_id, + }, nil end -local function build_envelope_url(dsn) - return string.format("%s://%s/api/%s/envelope/", - dsn.protocol, - dsn.host, - dsn.project_id) -end +local function build_envelope_url(dsn) return string.format("%s://%s/api/%s/envelope/", dsn.protocol, dsn.host, dsn.project_id) end local function build_auth_header(dsn) - local auth_parts = { - "Sentry sentry_version=7", - "sentry_key=" .. dsn.public_key, - "sentry_client=sentry-lua/" .. version - } + local auth_parts = { + "Sentry sentry_version=7", + "sentry_key=" .. dsn.public_key, + "sentry_client=sentry-lua/" .. version, + } - if dsn.secret_key and dsn.secret_key ~= "" then - table.insert(auth_parts, "sentry_secret=" .. dsn.secret_key) - end + if dsn.secret_key and dsn.secret_key ~= "" then table.insert(auth_parts, "sentry_secret=" .. dsn.secret_key) end - return table.concat(auth_parts, ", ") + return table.concat(auth_parts, ", ") end return { - parse_dsn = parse_dsn, - build_envelope_url = build_envelope_url, - build_auth_header = build_auth_header -} \ No newline at end of file + parse_dsn = parse_dsn, + build_envelope_url = build_envelope_url, + build_auth_header = build_auth_header, +} diff --git a/src/sentry/core/scope.lua b/src/sentry/core/scope.lua index a194e11..70cb146 100644 --- a/src/sentry/core/scope.lua +++ b/src/sentry/core/scope.lua @@ -8,35 +8,35 @@ Scope.contexts = {} Scope.breadcrumbs = {} function Scope:new() - print("scope:new") - local scope = setmetatable({ - max_breadcrumbs = 100 - }, {__index = self}) - return scope + print("scope:new") + local scope = setmetatable({ + max_breadcrumbs = 100, + }, { __index = self }) + return scope end function Scope:clone() - local new_scope = Scope:new() - for k, v in pairs(self.user) do - new_scope.user[k] = v - end - return new_scope + local new_scope = Scope:new() + for k, v in pairs(self.user) do + new_scope.user[k] = v + end + return new_scope end function Scope:add_breadcrumb(breadcrumb) - local crumb = { - -- TODO: os.time won't work on Roblox? - -- timestamp = os.time(), - message = breadcrumb.message or "", - category = breadcrumb.category or "default", - level = breadcrumb.level or "info", - data = breadcrumb.data -- or {} - } - table.insert(self.breadcrumbs, crumb) + local crumb = { + -- TODO: os.time won't work on Roblox? + -- timestamp = os.time(), + message = breadcrumb.message or "", + category = breadcrumb.category or "default", + level = breadcrumb.level or "info", + data = breadcrumb.data, -- or {} + } + table.insert(self.breadcrumbs, crumb) - while #self.breadcrumbs > self.max_breadcrumbs do - table.remove(self.breadcrumbs, 1) - end + while #self.breadcrumbs > self.max_breadcrumbs do + table.remove(self.breadcrumbs, 1) + end end -return Scope \ No newline at end of file +return Scope diff --git a/src/sentry/core/transport.lua b/src/sentry/core/transport.lua index b09c55c..f86da58 100644 --- a/src/sentry/core/transport.lua +++ b/src/sentry/core/transport.lua @@ -5,80 +5,66 @@ local Transport = {} Transport.__index = Transport function Transport:new(dsn, options) - options = options or {} - local transport = setmetatable({ - dsn = dsn, - queue = {}, - max_queue_size = options.max_queue_size or 30, - max_concurrent = options.max_concurrent or 5, - timeout_ms = options.timeout_ms or 5000, - processing = false, - active_requests = 0, - }, {__index = self}) - return transport + options = options or {} + local transport = setmetatable({ + dsn = dsn, + queue = {}, + max_queue_size = options.max_queue_size or 30, + max_concurrent = options.max_concurrent or 5, + timeout_ms = options.timeout_ms or 5000, + processing = false, + active_requests = 0, + }, { __index = self }) + return transport end function Transport:_process_queue() - if self.processing or #self.queue == 0 or self.active_requests >= self.max_concurrent then - return - end + if self.processing or #self.queue == 0 or self.active_requests >= self.max_concurrent then return end - self.processing = true + self.processing = true - local event = table.remove(self.queue, 1) - if event then - self.active_requests = self.active_requests + 1 - platform.http_post_async( - self.dsn, - self:_serialize_event(event), - { - ["Content-Type"] = "application/json", - ["User-Agent"] = "sentry-lua/1.0.0", - }, - { timeout_ms = self.timeout_ms }, - function(ok, status, body) - self.active_requests = self.active_requests - 1 - if not ok then - local msg = "Failed to send event to Sentry: " .. tostring(status) - if body and body ~= "" then - msg = msg .. ", response: " .. tostring(body) - end - logger.error(msg) - elseif status < 200 or status >= 300 then - local msg = "Sentry responded with error status: " .. tostring(status) - if body and body ~= "" then - msg = msg .. ", response: " .. tostring(body) - end - logger.error(msg) - else - logger.debug("Event sent to Sentry, status: " .. tostring(status)) - end - self:_process_queue() - end - ) - end + local event = table.remove(self.queue, 1) + if event then + self.active_requests = self.active_requests + 1 + platform.http_post_async(self.dsn, self:_serialize_event(event), { + ["Content-Type"] = "application/json", + ["User-Agent"] = "sentry-lua/1.0.0", + }, { timeout_ms = self.timeout_ms }, function(ok, status, body) + self.active_requests = self.active_requests - 1 + if not ok then + local msg = "Failed to send event to Sentry: " .. tostring(status) + if body and body ~= "" then msg = msg .. ", response: " .. tostring(body) end + logger.error(msg) + elseif status < 200 or status >= 300 then + local msg = "Sentry responded with error status: " .. tostring(status) + if body and body ~= "" then msg = msg .. ", response: " .. tostring(body) end + logger.error(msg) + else + logger.debug("Event sent to Sentry, status: " .. tostring(status)) + end + self:_process_queue() + end) + end - self.processing = false + self.processing = false - if #self.queue > 0 and self.active_requests < self.max_concurrent then - self:_process_queue() - end + if #self.queue > 0 and self.active_requests < self.max_concurrent then self:_process_queue() end end function Transport:_serialize_event(event) - local serialized = '{"message":"' .. (event.message or "") .. '","timestamp":"' .. platform.timestamp() .. '"}' - return serialized + local serialized = '{"message":"' .. (event.message or "") .. '","timestamp":"' .. platform.timestamp() .. '"}' + return serialized end function Transport:send_event(event) - if #self.queue >= self.max_queue_size then - logger.warn("Queue full, dropping event") - return false - end + if #self.queue >= self.max_queue_size then + logger.warn("Queue full, dropping event") + return false + end - table.insert(self.queue, event) - self:_process_queue() - return true + table.insert(self.queue, event) + self:_process_queue() + return true end -return Transport \ No newline at end of file +return Transport diff --git a/src/sentry/core/version.lua b/src/sentry/core/version.lua index 8f07a97..a36784c 100644 --- a/src/sentry/core/version.lua +++ b/src/sentry/core/version.lua @@ -3,4 +3,4 @@ local VERSION = "0.0.7" -return VERSION \ No newline at end of file +return VERSION diff --git a/src/sentry/init.lua b/src/sentry/init.lua index 956c6a7..cb5ab75 100644 --- a/src/sentry/init.lua +++ b/src/sentry/init.lua @@ -1,110 +1,106 @@ -- If 'debug' is available (not everywhere, e.g: Roblox), use it to make 'require's call relative to local directory if type(debug) == "table" and type(debug.getinfo) == "function" then -local info = debug.getinfo(1, "S") - if info and type(info.source) == "string" and info.source:sub(1,1) == "@" then - -- handle / and \ (Windows) - local local_dir = info.source:match("@(.*/)") or info.source:match("@(.*\\)") - if local_dir then - package.path = local_dir .. "?.lua;" .. local_dir .. "?/init.lua;" .. package.path - end - end + local info = debug.getinfo(1, "S") + if info and type(info.source) == "string" and info.source:sub(1, 1) == "@" then + -- handle / and \ (Windows) + local local_dir = info.source:match("@(.*/)") or info.source:match("@(.*\\)") + if local_dir then package.path = local_dir .. "?.lua;" .. local_dir .. "?/init.lua;" .. package.path end + end end local platform = require("platforms")() print(platform.name, platform.timestamp()) -local Client = require("core.client"); -local Scope = require("core.scope"); -local logger = require("core.diagnostic_logger"); +local Client = require("core.client") +local Scope = require("core.scope") +local logger = require("core.diagnostic_logger") local sentry = {} local function init(options) - logger.debug("init") - sentry._client = Client:new(options) - sentry._scope = Scope:new() + logger.debug("init") + sentry._client = Client:new(options) + sentry._scope = Scope:new() end local function capture_message(message, level) - if not sentry._client then - logger.debug("Sentry SDK has not been initialized") - return - end - return sentry._client:capture_message(message) + if not sentry._client then + logger.debug("Sentry SDK has not been initialized") + return + end + return sentry._client:capture_message(message) end local function capture_exception(exception) - if not sentry._client then - logger.debug("Sentry not initialized. Call sentry.init() first.") - return - end - logger.debug("capture_exception") + if not sentry._client then + logger.debug("Sentry not initialized. Call sentry.init() first.") + return + end + logger.debug("capture_exception") end local function add_breadcrumb(exception) - if not sentry._client then - logger.debug("Sentry not initialized. Call sentry.init() first.") - return - end - logger.debug("add_breadcrumb") + if not sentry._client then + logger.debug("Sentry not initialized. Call sentry.init() first.") + return + end + logger.debug("add_breadcrumb") end local function with_scope(callback) - if not sentry._client then - logger.debug("Sentry SDK has not been initialized. Executing callback for program continuity but no error handling will be active") - return - end + if not sentry._client then + logger.debug("Sentry SDK has not been initialized. Executing callback for program continuity but no error handling will be active") + return + end - logger.debug("with_scope") - local new_scope = sentry._scope:clone() + logger.debug("with_scope") + local new_scope = sentry._scope:clone() - local success, result = pcall(callback, new_scope) + local success, result = pcall(callback, new_scope) - if not success then - logger.error("callback failed to run through with_scope: " .. result) - end + if not success then logger.error("callback failed to run through with_scope: " .. result) end end local function set_tag(key, value) - if not sentry._client then - logger.debug("Sentry not initialized. Call sentry.init() first.") - return - end - logger.debug("set_tag") + if not sentry._client then + logger.debug("Sentry not initialized. Call sentry.init() first.") + return + end + logger.debug("set_tag") end local function set_extra(key, value) - if not sentry._client then - logger.debug("Sentry not initialized. Call sentry.init() first.") - return - end - logger.debug("set_extra") + if not sentry._client then + logger.debug("Sentry not initialized. Call sentry.init() first.") + return + end + logger.debug("set_extra") end local function set_user(key, value) - if not sentry._client then - logger.debug("Sentry not initialized. Call sentry.init() first.") - return - end - logger.debug("set_user") + if not sentry._client then + logger.debug("Sentry not initialized. Call sentry.init() first.") + return + end + logger.debug("set_user") end local function flush(key, value) - if not sentry._client then - logger.debug("Sentry not initialized. Call sentry.init() first.") - return - end - logger.debug("flush") + if not sentry._client then + logger.debug("Sentry not initialized. Call sentry.init() first.") + return + end + logger.debug("flush") end local function close() - if not sentry._client then - logger.debug("Sentry not initialized. Call sentry.init() first.") - return - end - logger.debug("close") + if not sentry._client then + logger.debug("Sentry not initialized. Call sentry.init() first.") + return + end + logger.debug("close") end -sentry.init = init; -sentry.capture_message = capture_message; -sentry.capture_exception = capture_exception; -sentry.add_breadcrumb = add_breadcrumb; -sentry.with_scope = with_scope; -sentry.set_tag = set_tag; -sentry.set_extra = set_extra; -sentry.set_user = set_user; -sentry.flush = flush; -sentry.close = close; +sentry.init = init +sentry.capture_message = capture_message +sentry.capture_exception = capture_exception +sentry.add_breadcrumb = add_breadcrumb +sentry.with_scope = with_scope +sentry.set_tag = set_tag +sentry.set_extra = set_extra +sentry.set_user = set_user +sentry.flush = flush +sentry.close = close -return sentry \ No newline at end of file +return sentry diff --git a/src/sentry/platforms/defold.lua b/src/sentry/platforms/defold.lua index 1315629..f170cee 100644 --- a/src/sentry/platforms/defold.lua +++ b/src/sentry/platforms/defold.lua @@ -2,41 +2,42 @@ local function tz_suffix(t) local z = os.date("%z", t or os.time()) or "+0000" - return string.format("%s%s:%s", z:sub(1,1), z:sub(2,3), z:sub(4,5)) + return string.format("%s%s:%s", z:sub(1, 1), z:sub(2, 3), z:sub(4, 5)) end local M = { name = "defold" } function M.timestamp() local sec = os.time() - local ms = 0 -- Defold core Lua lacks a standard ms wall clock - local d = os.date("*t", sec) - return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", - d.year,d.month,d.day,d.hour,d.min,d.sec,ms,tz_suffix(sec)) + local ms = 0 -- Defold core Lua lacks a standard ms wall clock + local d = os.date("*t", sec) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) end function M.http_post(url, body, headers, opts) headers = headers or {} - local result, done = { ok=false }, false + local result, done = { ok = false }, false ----@diagnostic disable-next-line: undefined-global + ---@diagnostic disable-next-line: undefined-global http.request(url, "POST", function(self, id, response) - result.ok = (response.status >= 200 and response.status < 400) - result.status = response.status - result.body = response.response + result.ok = (response.status >= 200 and response.status < 400) + result.status = response.status + result.body = response.response result.headers = response.headers done = true end, headers, body or "") -- block until done (must be inside coroutine or engine update loop) - while not done do coroutine.yield() end + while not done do + coroutine.yield() + end if not result.ok then return nil, tostring(result.status or "request failed") end return true, result.status, result.body, result.headers end function M.http_post_async(url, body, headers, _, callback) headers = headers or {} ----@diagnostic disable-next-line: undefined-global + ---@diagnostic disable-next-line: undefined-global http.request(url, "POST", function(self, id, response) if callback then local ok = (response.status >= 200 and response.status < 400) @@ -47,8 +48,10 @@ end function M.sleep(ms) local t0 = os.clock() - local target = (ms or 0)/1000 - while os.clock() - t0 < target do coroutine.yield() end + local target = (ms or 0) / 1000 + while os.clock() - t0 < target do + coroutine.yield() + end return true end diff --git a/src/sentry/platforms/init.lua b/src/sentry/platforms/init.lua index 05b803e..10741f4 100644 --- a/src/sentry/platforms/init.lua +++ b/src/sentry/platforms/init.lua @@ -1,58 +1,50 @@ -local M = nil -- will cache the chosen adapter +local M = nil -- will cache the chosen adapter local function detect() - -- OpenResty: ngx present with config ----@diagnostic disable-next-line: undefined-global - if rawget(_G, "ngx") and ngx and ngx.config then - return require("platforms.openresty") - end - -- Roblox: DateTime + HttpService/Game presence is a good signal - if rawget(_G, "DateTime") and rawget(_G, "game") then - return require("platforms.roblox") - end - - -- Defold: global 'http' (http.request), 'sys' module, and go module exist - if rawget(_G, "http") and rawget(_G, "sys") and rawget(_G, "go") then - return require("platforms.defold") - end - - -- LÖVE: global 'love' with version - ---@diagnostic disable-next-line: undefined-global - if rawget(_G, "love") and type(love) == "table" and love._version then - return require("platforms.love2d") - end - - -- Fallback to standard Lua - return require("platforms.lua") + -- OpenResty: ngx present with config + ---@diagnostic disable-next-line: undefined-global + if rawget(_G, "ngx") and ngx and ngx.config then return require("platforms.openresty") end + -- Roblox: DateTime + HttpService/Game presence is a good signal + if rawget(_G, "DateTime") and rawget(_G, "game") then return require("platforms.roblox") end + + -- Defold: global 'http' (http.request), 'sys' module, and go module exist + if rawget(_G, "http") and rawget(_G, "sys") and rawget(_G, "go") then return require("platforms.defold") end + + -- LÖVE: global 'love' with version + ---@diagnostic disable-next-line: undefined-global + if rawget(_G, "love") and type(love) == "table" and love._version then return require("platforms.love2d") end + + -- Fallback to standard Lua + return require("platforms.lua") end local function debug(err) - print("Failed loading platform: " .. err) ----@diagnostic disable-next-line: undefined-global - print(_VERSION, jit and jit.version or "no-jit") - print("cwd:", (io.popen and io.popen("pwd"):read("*l")) or "") - print("package.path =", package.path) - print("package.cpath =", package.cpath) - - local ok1, ssl = pcall(require, "ssl") - print("require ssl =", ok1, ok1 and (ssl._VERSION or "") or ssl) - - local ok2, https = pcall(require, "ssl.https") - print("require ssl.https =", ok2, ok2 and "ok" or https) + print("Failed loading platform: " .. err) + ---@diagnostic disable-next-line: undefined-global + print(_VERSION, jit and jit.version or "no-jit") + print("cwd:", (io.popen and io.popen("pwd"):read("*l")) or "") + print("package.path =", package.path) + print("package.cpath =", package.cpath) + + local ok1, ssl = pcall(require, "ssl") + print("require ssl =", ok1, ok1 and (ssl._VERSION or "") or ssl) + + local ok2, https = pcall(require, "ssl.https") + print("require ssl.https =", ok2, ok2 and "ok" or https) end local function get() - if not M then - local ok, val = xpcall(detect, debug) - if not ok then error("failed to load Sentry") end - M = val - end - assert(M.http_post, "http_post missing on " .. M.name) - assert(M.timestamp, "timestamp missing on " .. M.name) - return M + if not M then + local ok, val = xpcall(detect, debug) + if not ok then error("failed to load Sentry") end + M = val + end + assert(M.http_post, "http_post missing on " .. M.name) + assert(M.timestamp, "timestamp missing on " .. M.name) + return M end return setmetatable({}, { - __index = function(_, k) return get()[k] end, -- proxy functions/fields - __call = function() return get() end, -- allow require(... )() to fetch table + __index = function(_, k) return get()[k] end, -- proxy functions/fields + __call = function() return get() end, -- allow require(... )() to fetch table }) diff --git a/src/sentry/platforms/love2d.lua b/src/sentry/platforms/love2d.lua index 9076a2d..1115464 100644 --- a/src/sentry/platforms/love2d.lua +++ b/src/sentry/platforms/love2d.lua @@ -4,7 +4,10 @@ local M = { name = "love2d" } -- Required imports -local function safe_require(n) local ok, m = pcall(require, n); return ok and m or nil end +local function safe_require(n) + local ok, m = pcall(require, n) + return ok and m or nil +end local https = safe_require("ssl.https") if not https then error("https not available (install LuaSec: ssl.https)") end local ltn12 = safe_require("ltn12") @@ -13,7 +16,7 @@ if not ltn12 then error("ltn12 not available (install ltn12)") end local function tz_suffix(t) local z = os.date("%z", t or os.time()) or "+0000" assert(type(z) == "string") - return string.format("%s%s:%s", z:sub(1,1), z:sub(2,3), z:sub(4,5)) + return string.format("%s%s:%s", z:sub(1, 1), z:sub(2, 3), z:sub(4, 5)) end -- Try to grab LuaSocket once @@ -30,59 +33,56 @@ if socket and type(socket.gettime) == "function" then timestamp_impl = function() local now = socket.gettime() local sec = math.floor(now) - local ms = math.floor((now - sec) * 1000 + 0.5) - if ms == 1000 then ms, sec = 0, sec + 1 end + local ms = math.floor((now - sec) * 1000 + 0.5) + if ms == 1000 then + ms, sec = 0, sec + 1 + end local d = os.date("*t", sec) - return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", - d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) end - elseif love and love.timer and love.timer.getTime then -- Fallback: blend os.time() with love.timer high-res clock timestamp_impl = function() local elapsed = love.timer.getTime() - base_mono local now = base_wall + elapsed local sec = math.floor(now) - local ms = math.floor((now - sec) * 1000 + 0.5) - if ms == 1000 then ms, sec = 0, sec + 1 end + local ms = math.floor((now - sec) * 1000 + 0.5) + if ms == 1000 then + ms, sec = 0, sec + 1 + end local d = os.date("*t", sec) - return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", - d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) end - else -- Last resort: os.time() only timestamp_impl = function() local sec, ms = os.time(), 0 local d = os.date("*t", sec) - return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", - d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) end end -- Public API -function M.timestamp() - return timestamp_impl() -end +function M.timestamp() return timestamp_impl() end local function set_timeout(lib, ms) - if lib and ms then lib.TIMEOUT = math.max(0.001, ms/1000) end + if lib and ms then lib.TIMEOUT = math.max(0.001, ms / 1000) end end local function request_with_optional_headers(lib, url, headers, body, timeout_ms) set_timeout(lib, timeout_ms) headers = headers or {} - body = body or "" + body = body or "" if ltn12 then local chunks = {} - local ok, code, resp_headers = lib.request{ + local ok, code, resp_headers = lib.request({ url = url, method = "POST", headers = headers, source = ltn12.source.string(body), - sink = ltn12.sink.table(chunks), - } + sink = ltn12.sink.table(chunks), + }) if not ok then return nil, tostring(code or "request failed") end return true, tonumber(code), table.concat(chunks), resp_headers end @@ -94,16 +94,14 @@ end function M.http_post(url, body, headers, opts) opts = opts or {} - return request_with_optional_headers(https, url, headers, body, opts.timeout_ms) + return request_with_optional_headers(https, url, headers, body, opts.timeout_ms) end function M.http_post_async(url, body, headers, opts, callback) -- luacheck: no unused secondaries ---@diagnostic disable-next-line: unused-local local ok, status, resp_body, resp_headers = M.http_post(url, body, headers, opts) - if callback then - callback(ok, status, resp_body) - end + if callback then callback(ok, status, resp_body) end end return M diff --git a/src/sentry/platforms/lua.lua b/src/sentry/platforms/lua.lua index 2e68025..8489981 100644 --- a/src/sentry/platforms/lua.lua +++ b/src/sentry/platforms/lua.lua @@ -5,22 +5,18 @@ local function ensure_https() if ok then return https end -- Hacks to get the debugger to work on a mac - local lr_path = "~/.luarocks/share/lua/5.4/?.lua;~/.luarocks/share/lua/5.4/?/init.lua;/opt/homebrew/share/lua/5.4/?.lua;/opt/homebrew/share/lua/5.4/?/init.lua" + local lr_path = "~/.luarocks/share/lua/5.4/?.lua;~/.luarocks/share/lua/5.4/?/init.lua;/opt/homebrew/share/lua/5.4/?.lua;/opt/homebrew/share/lua/5.4/?/init.lua" local lr_cpath = "~/.luarocks/lib/lua/5.4/?.so;/opt/homebrew/lib/lua/5.4/?.so" - if not package.path:find("/.luarocks/share/lua/5.4", 1, true) then - package.path = package.path .. ";" .. lr_path - end - if not package.cpath:find("/.luarocks/lib/lua/5.4", 1, true) then - package.cpath = package.cpath .. ";" .. lr_cpath - end + if not package.path:find("/.luarocks/share/lua/5.4", 1, true) then package.path = package.path .. ";" .. lr_path end + if not package.cpath:find("/.luarocks/lib/lua/5.4", 1, true) then package.cpath = package.cpath .. ";" .. lr_cpath end local ok2, https2 = pcall(require, "ssl.https") if ok2 then return https2 end -- Last resort: print why (helps when the debugger’s runtime/ABI mismatches) local msg = ("Cannot load ssl.https.\npackage.path=%s\npackage.cpath=%s"):format(package.path, package.cpath) - io.stderr:write(msg.."\n") + io.stderr:write(msg .. "\n") return nil end @@ -29,10 +25,13 @@ ensure_https() local function tz_suffix(t) local z = os.date("%z", t or os.time()) or "+0000" assert(type(z) == "string") - return string.format("%s%s:%s", z:sub(1,1), z:sub(2,3), z:sub(4,5)) + return string.format("%s%s:%s", z:sub(1, 1), z:sub(2, 3), z:sub(4, 5)) end -local function safe_require(n) local ok, m = pcall(require, n); return ok and m or nil end +local function safe_require(n) + local ok, m = pcall(require, n) + return ok and m or nil +end local https = safe_require("ssl.https") if not https then error("https not available (install LuaSec: ssl.https)") end @@ -57,31 +56,29 @@ function M.timestamp() sec, ms = os.time(), 0 end local d = os.date("*t", sec) - return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", - d.year,d.month,d.day,d.hour,d.min,d.sec,ms,tz_suffix(sec)) + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03d%s", d.year, d.month, d.day, d.hour, d.min, d.sec, ms, tz_suffix(sec)) end - local function set_timeout(lib, ms) - if lib and ms then lib.TIMEOUT = math.max(0.001, ms/1000) end + if lib and ms then lib.TIMEOUT = math.max(0.001, ms / 1000) end end local function request_with_optional_headers(lib, url, method, headers, body, timeout_ms) set_timeout(lib, timeout_ms) headers = headers or {} - body = body or "" + body = body or "" if ltn12 then local chunks = {} -- luacheck: no unused secondaries ---@diagnostic disable-next-line: unused-local - local ok, code, resp_headers, status = lib.request{ + local ok, code, resp_headers, status = lib.request({ url = url, method = method, headers = headers, source = ltn12.source.string(body), - sink = ltn12.sink.table(chunks), - } + sink = ltn12.sink.table(chunks), + }) if not ok then return nil, tostring(code or "request failed: ") end return true, tonumber(code), table.concat(chunks), resp_headers end @@ -93,16 +90,14 @@ end function M.http_post(url, body, headers, opts) opts = opts or {} - return request_with_optional_headers(https, url, "POST", headers, body, opts.timeout_ms) + return request_with_optional_headers(https, url, "POST", headers, body, opts.timeout_ms) end function M.http_post_async(url, body, headers, opts, callback) -- luacheck: no unused secondaries ---@diagnostic disable-next-line: unused-local local ok, status, resp_body, resp_headers = M.http_post(url, body, headers, opts) - if callback then - callback(ok, status, resp_body) - end + if callback then callback(ok, status, resp_body) end end return M diff --git a/src/sentry/platforms/openresty.lua b/src/sentry/platforms/openresty.lua index 0faff77..01693b5 100644 --- a/src/sentry/platforms/openresty.lua +++ b/src/sentry/platforms/openresty.lua @@ -1,6 +1,9 @@ local M = { name = "openresty" } -local function safe_require(n) local ok, m = pcall(require, n); return ok and m or nil end +local function safe_require(n) + local ok, m = pcall(require, n) + return ok and m or nil +end local http = safe_require("resty.http") if not http then error("http not available") end @@ -9,9 +12,9 @@ function M.http_post(url, body, headers, opts) local httpc = http.new() if opts and opts.timeout_ms then httpc:set_timeout(opts.timeout_ms) end local res, err = httpc:request_uri(url, { - method = "POST", - body = body or "", - headers = headers or {}, + method = "POST", + body = body or "", + headers = headers or {}, keepalive = not (opts and opts.keepalive == false), }) if not res then return nil, err end @@ -19,24 +22,24 @@ function M.http_post(url, body, headers, opts) end function M.http_post_async(url, body, headers, opts, callback) ----@diagnostic disable-next-line: undefined-global + ---@diagnostic disable-next-line: undefined-global ngx.thread.spawn(function() local ok, status, resp_body = M.http_post(url, body, headers, opts) - if callback then - callback(ok, status, resp_body) - end + if callback then callback(ok, status, resp_body) end end) end function M.timestamp() ----@diagnostic disable-next-line: undefined-global - local now = ngx.now() -- seconds + fractional + ---@diagnostic disable-next-line: undefined-global + local now = ngx.now() -- seconds + fractional local sec = math.floor(now) - local ms = math.floor((now - sec) * 1000 + 0.5) - if ms == 1000 then ms = 0; sec = sec + 1 end - local d = os.date("!*t", sec) -- UTC - return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", - d.year,d.month,d.day,d.hour,d.min,d.sec,ms) + local ms = math.floor((now - sec) * 1000 + 0.5) + if ms == 1000 then + ms = 0 + sec = sec + 1 + end + local d = os.date("!*t", sec) -- UTC + return string.format("%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", d.year, d.month, d.day, d.hour, d.min, d.sec, ms) end return M diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000..f043113 --- /dev/null +++ b/stylua.toml @@ -0,0 +1,14 @@ +# StyLua configuration file + +column_width = 200 +line_endings = "Unix" +indent_type = "Spaces" +indent_width = 2 +quote_style = "AutoPreferDouble" +call_parentheses = "Always" + +# Collapse simple structures to single line if they fit +collapse_simple_statement = "Always" + +[sort_requires] +enabled = false \ No newline at end of file From 5041c129063b58f1e3a74fc7efb5dd880026a95d Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 16:59:44 -0400 Subject: [PATCH 17/23] stylua in ci + style issue to test it --- .github/workflows/test.yml | 21 ++++ README.md | 37 ++++++ scripts/README.md | 66 ++++++++++ scripts/dev.lua | 248 +++++++++++++++++++++++++++++++++++++ src/sentry/core/dsn.lua | 8 +- 5 files changed, 376 insertions(+), 4 deletions(-) create mode 100644 scripts/README.md create mode 100755 scripts/dev.lua diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9e6f2a2..913ee3d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,6 +34,27 @@ jobs: with: version: latest args: --check . + + - name: Format code with StyLua + if: failure() + uses: JohnnyMorganz/stylua-action@479972f01e665acfcba96ada452c36608bdbbb5e # v4.1.0 + with: + version: latest + args: . + + - name: Commit formatted code + if: failure() + run: | + if [[ $(git status --porcelain) ]]; then + echo "Code was formatted. Committing changes." + git config --global user.name 'Sentry GitHub Bot' + git config --global user.email 'bot+github-bot@sentry.io' + git add . + git commit -m "Format code with StyLua [skip ci]" + git push + else + echo "No formatting changes needed." + fi - name: Install luacheck run: luarocks install luacheck diff --git a/README.md b/README.md index d49f510..e6d92c1 100644 --- a/README.md +++ b/README.md @@ -119,3 +119,40 @@ For example, the LÖVE framework example app: ![Screenshot of this example app](./examples/love2d/example-app.png "LÖVE Example App") +## Development + +This project uses a cross-platform Lua development script instead of Make for better Windows compatibility. + +### Setup + +1. Install Lua and LuaRocks +2. Install dependencies: `lua scripts/dev.lua install` + +### Common Tasks + +```bash +# Run tests +lua scripts/dev.lua test + +# Run linter +lua scripts/dev.lua lint + +# Format code +lua scripts/dev.lua format + +# Run full CI pipeline locally +lua scripts/dev.lua ci + +# Show all available commands +lua scripts/dev.lua help +``` + +### Code Quality + +- **Linting**: luacheck for code quality +- **Formatting**: StyLua for consistent style +- **Testing**: busted for test framework +- **Coverage**: luacov for coverage reporting + +All tools run cross-platform via the `scripts/dev.lua` script. + diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..d7cbae3 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,66 @@ +# Development Scripts + +This directory contains development automation scripts for the Sentry Lua SDK. + +## dev.lua + +A cross-platform Lua script that replaces traditional Makefile functionality, ensuring compatibility across Windows, macOS, and Linux. + +### Usage + +```bash +# Show help +lua scripts/dev.lua help + +# Install all dependencies +lua scripts/dev.lua install + +# Run tests +lua scripts/dev.lua test + +# Run tests with coverage (generates luacov.report.out) +lua scripts/dev.lua coverage + +# Run linter +lua scripts/dev.lua lint + +# Check code formatting +lua scripts/dev.lua format-check + +# Format code +lua scripts/dev.lua format + +# Test rockspec installation +lua scripts/dev.lua test-rockspec + +# Clean build artifacts +lua scripts/dev.lua clean + +# Run full CI pipeline +lua scripts/dev.lua ci +``` + +### Requirements + +- Lua (5.1+) +- LuaRocks +- For formatting: StyLua (`cargo install stylua`) + +### Cross-Platform Commands + +The script automatically detects the platform and uses appropriate commands: + +- **Windows**: Uses `dir`, `rmdir`, `xcopy` +- **Unix/Linux/macOS**: Uses `ls`, `rm`, `cp` + +This ensures the same script works across all development environments without modification. + +### CI Integration + +The `ci` command runs the complete pipeline: +1. Linting with luacheck +2. Format checking with StyLua +3. Test suite with busted +4. Coverage reporting with luacov + +This matches what runs in GitHub Actions, allowing developers to run the same checks locally. \ No newline at end of file diff --git a/scripts/dev.lua b/scripts/dev.lua new file mode 100755 index 0000000..cb54e07 --- /dev/null +++ b/scripts/dev.lua @@ -0,0 +1,248 @@ +#!/usr/bin/env lua + +local function run_command(cmd, description) + if description then print("🔧 " .. description) end + print("$ " .. cmd) + local result = os.execute(cmd) + if result ~= 0 and result ~= true then + print("❌ Command failed: " .. cmd) + os.exit(1) + end + print("✅ Success") + print("") + return result +end + +local function file_exists(path) + local file = io.open(path, "r") + if file then + file:close() + return true + end + return false +end + +local function get_luarocks_path() + -- Try to detect luarocks installation + local handle = io.popen("luarocks path 2>/dev/null || echo ''") + local result = handle:read("*a") + handle:close() + return result and result ~= "" +end + +local function install_dependencies() + print("📦 Installing dependencies...") + + if not get_luarocks_path() then + print("❌ LuaRocks not found. Please install LuaRocks first.") + os.exit(1) + end + + -- Install test dependencies + run_command("luarocks install busted", "Installing busted test framework") + run_command("luarocks install lua-cjson", "Installing JSON support") + run_command("luarocks install luasocket", "Installing socket support") + run_command("luarocks install luasec", "Installing SSL/HTTPS support") + run_command("luarocks install luacov", "Installing coverage tool") + run_command("luarocks install luacov-reporter-lcov", "Installing LCOV reporter") + + -- Install development dependencies + run_command("luarocks install luacheck", "Installing luacheck linter") +end + +local function run_tests() + print("🧪 Running tests...") + + if not file_exists("spec") then + print("❌ No spec directory found") + os.exit(1) + end + + -- Run busted tests + run_command("busted", "Running test suite") +end + +local function run_coverage() + print("📊 Running tests with coverage...") + + -- Run tests with coverage + run_command("busted --coverage", "Running tests with coverage") + + -- Generate coverage reports + if file_exists("luacov.stats.out") then + run_command("luacov", "Generating standard coverage report") + if file_exists("luacov.report.out") then print("✅ Coverage report generated: luacov.report.out") end + + -- Try to generate LCOV report if reporter is available + local lcov_test = os.execute("luacov-reporter-lcov --help 2>/dev/null") + if lcov_test == 0 or lcov_test == true then + run_command("luacov-reporter-lcov", "Generating LCOV coverage report") + if file_exists("coverage.info") then print("✅ LCOV coverage report generated: coverage.info") end + else + print("ℹ️ LCOV reporter not available (install with: luarocks install luacov-reporter-lcov)") + print("📄 Standard coverage report available in luacov.report.out") + end + else + print("⚠️ No coverage stats found. Make sure busted --coverage ran successfully.") + end +end + +local function run_lint() + print("🔍 Running linter...") + + -- Try to find luacheck in common locations + local luacheck_paths = { + "luacheck", + "~/.luarocks/bin/luacheck", + "/usr/local/bin/luacheck", + os.getenv("HOME") .. "/.luarocks/bin/luacheck", + } + + local luacheck_cmd = nil + for _, path in ipairs(luacheck_paths) do + local expanded_path = path:gsub("~", os.getenv("HOME") or "~") + local test_result = os.execute(expanded_path .. " --version 2>/dev/null") + if test_result == 0 or test_result == true then + luacheck_cmd = expanded_path + break + end + end + + if not luacheck_cmd then + print("❌ luacheck not found. Install with: luarocks install luacheck") + print(" Or ensure luacheck is in your PATH") + os.exit(1) + end + + -- Run luacheck + run_command(luacheck_cmd .. " .", "Running luacheck") +end + +local function run_format_check() + print("✨ Checking code formatting...") + + -- Check if stylua is available + local handle = io.popen("stylua --version 2>/dev/null") + local stylua_version = handle:read("*l") + handle:close() + + if not stylua_version then + print("⚠️ StyLua not found. Install with: cargo install stylua") + return + end + + -- Check formatting + local result = os.execute("stylua --check .") + if result ~= 0 and result ~= true then + print("❌ Code formatting issues found. Run: lua scripts/dev.lua format") + os.exit(1) + else + print("✅ Code formatting is correct") + end +end + +local function run_format() + print("✨ Formatting code...") + + -- Check if stylua is available + local handle = io.popen("stylua --version 2>/dev/null") + local stylua_version = handle:read("*l") + handle:close() + + if not stylua_version then + print("❌ StyLua not found. Install with: cargo install stylua") + os.exit(1) + end + + -- Format code + run_command("stylua .", "Formatting Lua code") +end + +local function test_rockspec() + print("📋 Testing rockspec installation...") + + -- Find rockspec file + local handle = io.popen("ls *.rockspec 2>/dev/null || dir *.rockspec 2>nul") + local rockspec = handle:read("*l") + handle:close() + + if not rockspec then + print("❌ No rockspec file found") + os.exit(1) + end + + -- Test installation + run_command("luarocks make " .. rockspec, "Testing rockspec installation") +end + +local function clean() + print("🧹 Cleaning build artifacts...") + + -- Clean coverage files + if file_exists("luacov.stats.out") then run_command("rm luacov.stats.out", "Removing coverage stats") end + if file_exists("luacov.report.out") then run_command("rm luacov.report.out", "Removing coverage report") end + if file_exists("coverage.info") then run_command("rm coverage.info", "Removing LCOV report") end + if file_exists("test-results.xml") then run_command("rm test-results.xml", "Removing test results") end +end + +local function show_help() + print("🚀 Sentry Lua SDK Development Script") + print("") + print("Usage: lua scripts/dev.lua [command]") + print("") + print("Commands:") + print(" install Install dependencies") + print(" test Run tests") + print(" coverage Run tests with coverage") + print(" lint Run linter (luacheck)") + print(" format-check Check code formatting") + print(" format Format code with StyLua") + print(" test-rockspec Test rockspec installation") + print(" clean Clean artifacts") + print(" ci Run full CI pipeline (lint, format-check, test, coverage)") + print(" help Show this help") + print("") + print("Examples:") + print(" lua scripts/dev.lua install") + print(" lua scripts/dev.lua test") + print(" lua scripts/dev.lua ci") +end + +local function run_ci() + print("🚀 Running full CI pipeline...") + run_lint() + run_format_check() + run_tests() + run_coverage() + print("🎉 CI pipeline completed successfully!") +end + +-- Main execution +local command = arg and arg[1] or "help" + +if command == "install" then + install_dependencies() +elseif command == "test" then + run_tests() +elseif command == "coverage" then + run_coverage() +elseif command == "lint" then + run_lint() +elseif command == "format-check" then + run_format_check() +elseif command == "format" then + run_format() +elseif command == "test-rockspec" then + test_rockspec() +elseif command == "clean" then + clean() +elseif command == "ci" then + run_ci() +elseif command == "help" then + show_help() +else + print("❌ Unknown command: " .. command) + print("") + show_help() + os.exit(1) +end diff --git a/src/sentry/core/dsn.lua b/src/sentry/core/dsn.lua index 1820fa1..e71ee66 100644 --- a/src/sentry/core/dsn.lua +++ b/src/sentry/core/dsn.lua @@ -8,7 +8,7 @@ local function parse_dsn(dsn_string) -- https://public_key@host:port/path/project_id local protocol, credentials, host_path = dsn_string:match("^(https?)://([^@]+)@(.+)$") - if not protocol or not credentials or not host_path then return {}, "Invalid DSN format" end + if not protocol or not credentials or not host_path then return nil, "Invalid DSN format" end -- Parse credentials (public_key or public_key:secret_key) local public_key, secret_key = credentials:match("^([^:]+):(.+)$") @@ -17,15 +17,15 @@ local function parse_dsn(dsn_string) secret_key = "" end - if not public_key or public_key == "" then return {}, "Invalid DSN format" end + if not public_key or public_key == "" then return nil, "Invalid DSN format" end -- Parse host and path local host, path = host_path:match("^([^/]+)(.*)$") - if not host or not path or path == "" then return {}, "Invalid DSN format" end + if not host or not path or path == "" then return nil, "Invalid DSN format" end -- Extract project ID from path (last numeric segment) local project_id = path:match("/([%d]+)$") - if not project_id then return {}, "Could not extract project ID from DSN" end + if not project_id then return nil, "Could not extract project ID from DSN" end -- Parse port from host local port = 443 From 9dc887d0bf49e668618faa23660bae5436896559 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 17:05:32 -0400 Subject: [PATCH 18/23] drop makefile --- .github/workflows/test.yml | 38 +++++++++++++++----------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 913ee3d..709613b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,18 +29,15 @@ jobs: - name: Setup LuaRocks uses: leafo/gh-actions-luarocks@e65774a6386cb4f24e293dca7fc4ff89165b64c5 # v4 - - name: Install StyLua - uses: JohnnyMorganz/stylua-action@479972f01e665acfcba96ada452c36608bdbbb5e # v4.1.0 - with: - version: latest - args: --check . + - name: Install dependencies + run: lua scripts/dev.lua install + + - name: Check formatting + run: lua scripts/dev.lua format-check - name: Format code with StyLua if: failure() - uses: JohnnyMorganz/stylua-action@479972f01e665acfcba96ada452c36608bdbbb5e # v4.1.0 - with: - version: latest - args: . + run: lua scripts/dev.lua format - name: Commit formatted code if: failure() @@ -56,11 +53,8 @@ jobs: echo "No formatting changes needed." fi - - name: Install luacheck - run: luarocks install luacheck - - - name: Run luacheck - run: luacheck . + - name: Run linter + run: lua scripts/dev.lua lint test: strategy: @@ -149,9 +143,9 @@ jobs: - name: Install dependencies shell: bash run: | - # For Ubuntu LuaJIT, use --local flag to avoid permission issues + # For Ubuntu LuaJIT, we need to handle luasec SSL configuration if [ "${{ matrix.os }}" = "ubuntu-latest" ] && [[ "${{ matrix.lua-version }}" == *"luajit"* ]]; then - echo "Installing dependencies locally for Ubuntu LuaJIT..." + # Install dependencies individually to handle SSL configuration luarocks install --local busted luarocks install --local lua-cjson luarocks install --local luasocket @@ -163,17 +157,15 @@ jobs: fi luarocks install --local luacov luarocks install --local luacov-reporter-lcov + luarocks install --local luacheck # Add local luarocks bin to PATH echo "$HOME/.luarocks/bin" >> $GITHUB_PATH else - make install + lua scripts/dev.lua install fi - - name: Build project - run: make build - - name: Run tests with coverage - run: make coverage-report + run: lua scripts/dev.lua coverage - name: Validate rockspec installation shell: bash @@ -181,9 +173,9 @@ jobs: # For Ubuntu LuaJIT, update the rockspec test to use local luarocks path if [ "${{ matrix.os }}" = "ubuntu-latest" ] && [[ "${{ matrix.lua-version }}" == *"luajit"* ]]; then eval "$(luarocks path --local)" - make test-rockspec + lua scripts/dev.lua test-rockspec else - make test-rockspec + lua scripts/dev.lua test-rockspec fi - name: Upload coverage artifacts From 14f810ee1210f40d3e632f925d9f9d8f1e5ecd53 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 17:11:50 -0400 Subject: [PATCH 19/23] ci and validations --- .github/workflows/test.yml | 5 ++--- scripts/dev.lua | 16 ++++------------ sentry-0.0.7-1.rockspec | 2 +- 3 files changed, 7 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 709613b..f58a914 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -183,16 +183,15 @@ jobs: with: name: coverage-${{ matrix.os }}-lua${{ matrix.lua-version }} path: | - coverage.info luacov.report.out - test-results.xml + luacov.stats.out retention-days: 7 - name: Upload coverage to Codecov (Ubuntu Lua 5.4 only) if: matrix.os == 'ubuntu-latest' && matrix.lua-version == '5.4' uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 with: - file: ./coverage.info + file: ./luacov.report.out flags: unittests name: codecov-${{ matrix.lua-version }} fail_ci_if_error: false diff --git a/scripts/dev.lua b/scripts/dev.lua index cb54e07..d91c985 100755 --- a/scripts/dev.lua +++ b/scripts/dev.lua @@ -59,7 +59,7 @@ local function run_tests() end -- Run busted tests - run_command("busted", "Running test suite") + run_command("busted", "Running test suite") end local function run_coverage() @@ -70,17 +70,9 @@ local function run_coverage() -- Generate coverage reports if file_exists("luacov.stats.out") then - run_command("luacov", "Generating standard coverage report") - if file_exists("luacov.report.out") then print("✅ Coverage report generated: luacov.report.out") end - - -- Try to generate LCOV report if reporter is available - local lcov_test = os.execute("luacov-reporter-lcov --help 2>/dev/null") - if lcov_test == 0 or lcov_test == true then - run_command("luacov-reporter-lcov", "Generating LCOV coverage report") - if file_exists("coverage.info") then print("✅ LCOV coverage report generated: coverage.info") end - else - print("ℹ️ LCOV reporter not available (install with: luarocks install luacov-reporter-lcov)") - print("📄 Standard coverage report available in luacov.report.out") + run_command("luacov", "Generating coverage report") + if file_exists("luacov.report.out") then + print("✅ Coverage report generated: luacov.report.out") end else print("⚠️ No coverage stats found. Make sure busted --coverage ran successfully.") diff --git a/sentry-0.0.7-1.rockspec b/sentry-0.0.7-1.rockspec index 45cfaed..b13611a 100644 --- a/sentry-0.0.7-1.rockspec +++ b/sentry-0.0.7-1.rockspec @@ -1,5 +1,5 @@ rockspec_format = "3.0" -package = "sdk" +package = "sentry" version = "0.0.7-1" source = { url = "git+https://github.com/getsentry/sentry-lua.git", From c6f4a7af6ac22b824f1bf2fc093826b77686452b Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 17:36:04 -0400 Subject: [PATCH 20/23] love2d sample post refactor (broken still) --- .github/workflows/love2d.yml | 8 ++--- .luacheckrc | 4 +++ examples/love2d/README.md | 4 +-- examples/love2d/main.lua | 60 ++++++++++++------------------------ examples/love2d/sentry | 2 +- examples/wrap_demo.lua | 2 +- scripts/dev.lua | 9 ++++++ 7 files changed, 39 insertions(+), 50 deletions(-) diff --git a/.github/workflows/love2d.yml b/.github/workflows/love2d.yml index 16f8a55..4aedc1d 100644 --- a/.github/workflows/love2d.yml +++ b/.github/workflows/love2d.yml @@ -44,15 +44,15 @@ jobs: echo "OPENSSL_DIR=$(brew --prefix openssl)" >> $GITHUB_ENV - name: Install dependencies - run: make install + run: lua scripts/dev.lua install - - name: Install Love2D and run tests - run: make ci-love2d + - name: Run Love2D tests + run: lua scripts/dev.lua ci-love2d - name: Test example module loading run: | cd examples/love2d - LUA_PATH="../../build/?.lua;../../build/?/init.lua;;" lua test_modules.lua + LUA_PATH="../../src/?.lua;../../src/?/init.lua;;" lua test_modules.lua - name: Package Love2D example run: | diff --git a/.luacheckrc b/.luacheckrc index 21b4f95..3f19786 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -39,6 +39,10 @@ files["src/sentry/platforms/init.lua"] = { globals = platform_bootstrapper } ignore = { "212", -- unused argument (common in callbacks) "213", -- unused loop variable (common in iterations) + "611", -- A line consists of nothing but whitespace. + "612", -- A line contains trailing whitespace. + "613", -- Trailing whitespace in a string. + "614", -- Trailing whitespace in a comment. } max_line_length = 200 diff --git a/examples/love2d/README.md b/examples/love2d/README.md index dda7bd2..1f457c5 100644 --- a/examples/love2d/README.md +++ b/examples/love2d/README.md @@ -88,10 +88,8 @@ The demo sends data to the Sentry playground project. In production, you would: When integrating Sentry into your Love2D game: -1. **Add SDK to Project**: Copy `build/sentry/` into your Love2D project +1. **Add SDK to Project**: Copy `src/sentry/` into your Love2D project 2. **Initialize Early**: Call `sentry.init()` in `love.load()` -3. **Flush Regularly**: Call `transport:flush()` in `love.update()` -4. **Clean Shutdown**: Call `transport:close()` in `love.quit()` 5. **Add Context**: Use breadcrumbs and tags for better debugging ### Clean API Usage diff --git a/examples/love2d/main.lua b/examples/love2d/main.lua index dc43818..08cb85b 100644 --- a/examples/love2d/main.lua +++ b/examples/love2d/main.lua @@ -2,10 +2,9 @@ -- A simple app with Sentry logo and error button to demonstrate Sentry SDK -- Sentry SDK available in local sentry/ directory --- In production, copy build/sentry/ into your Love2D project +-- In production, copy src/sentry/ into your Love2D project local sentry = require("sentry") -local logger = require("sentry.logger") -- Game state local game = { @@ -67,14 +66,6 @@ function love.load() print("[Debug] No transport found!") end - -- Initialize logger - logger.init({ - enable_logs = true, - max_buffer_size = 5, - flush_timeout = 2.0, - hook_print = true, - }) - -- Set user context sentry.set_user({ id = "love2d_user_" .. math.random(1000, 9999), @@ -114,17 +105,17 @@ function love.load() -- Initialize demo functions for multi-frame stack traces game.demo_functions = { level1 = function(user_action, error_type) - logger.info("Level 1: Processing user action %s", { user_action }) + print("Level 1: Processing user action %s", { user_action }) return game.demo_functions.level2(error_type, "processing") end, level2 = function(action_type, status) - logger.debug("Level 2: Executing %s with status %s", { action_type, status }) + print("Level 2: Executing %s with status %s", { action_type, status }) return game.demo_functions.level3(action_type) end, level3 = function(error_category) - logger.warn("Level 3: About to trigger %s error", { error_category }) + warn("Level 3: About to trigger %s error", { error_category }) return game.demo_functions.trigger_error(error_category) end, @@ -134,22 +125,19 @@ function love.load() -- Create realistic error scenarios if category == "button_click" then - logger.error("Critical error in button handler") error("Love2DButtonError: Button click handler failed with code " .. math.random(1000, 9999)) elseif category == "rendering" then - logger.error("Graphics rendering failure") error("Love2DRenderError: Failed to render game object at frame " .. love.timer.getTime()) else - logger.error("Generic game error occurred") error("Love2DGameError: Unexpected game state error in category " .. tostring(category)) end end, } -- Log successful initialization - logger.info("Love2D Sentry demo initialized successfully") - logger.info("Love2D version: %s", { table.concat({ love.getVersion() }, ".") }) - logger.info("Operating system: %s", { love.system.getOS() }) + print("Love2D Sentry demo initialized successfully") + print("Love2D version: %s", { table.concat({ love.getVersion() }, ".") }) + print("Operating system: %s", { love.system.getOS() }) -- Add breadcrumb for debugging context sentry.add_breadcrumb({ @@ -170,16 +158,10 @@ function love.update(dt) -- Log hover state changes if button.hover and not was_hover then - logger.debug("Button hover state: entered") + print("Button hover state: entered") elseif not button.hover and was_hover then - logger.debug("Button hover state: exited") + print("Button hover state: exited") end - - -- Flush Sentry transport periodically - sentry.flush() - - -- Flush logger periodically - if math.floor(love.timer.getTime()) % 3 == 0 then logger.flush() end end function love.draw() @@ -280,19 +262,17 @@ function love.mousepressed(x, y, button_num, istouch, presses) }) -- Log the button click - logger.info("Error button clicked at position (%s, %s)", { x, y }) - logger.info("Preparing to trigger multi-frame error...") + print("Error button clicked at position (%s, %s)", { x, y }) + print("Preparing to trigger multi-frame error...") -- Use xpcall to capture the error with original stack trace local function error_handler(err) - logger.error("Button click error occurred: %s", { tostring(err) }) - sentry.capture_exception({ type = "Love2DUserTriggeredError", message = tostring(err), }) - logger.info("Error captured and sent to Sentry") + print("Error captured and sent to Sentry") return err end @@ -316,8 +296,8 @@ function love.mousepressed(x, y, button_num, istouch, presses) }) -- Log the fatal button click - logger.info("Fatal error button clicked at position (%s, %s)", { x, y }) - logger.info("This will trigger love.errorhandler and crash the app...") + print("Fatal error button clicked at position (%s, %s)", { x, y }) + print("This will trigger love.errorhandler and crash the app...") -- Trigger a fatal error that will go through love.errorhandler -- This error is NOT caught with xpcall, so it will bubble up to love.errorhandler @@ -336,15 +316,13 @@ end function love.keypressed(key) if key == "escape" then -- Clean shutdown with Sentry flush - logger.info("Application shutting down") - logger.flush() - + print("Application shutting down") sentry.close() love.event.quit() elseif key == "r" then -- Trigger rendering error - logger.info("Rendering error triggered via keyboard") + print("Rendering error triggered via keyboard") sentry.add_breadcrumb({ message = "Rendering error triggered", @@ -363,7 +341,7 @@ function love.keypressed(key) xpcall(function() game.demo_functions.level1("render_test", "rendering") end, error_handler) elseif key == "f" then -- Trigger fatal error via keyboard - logger.info("Fatal error triggered via keyboard - will crash app") + print("Fatal error triggered via keyboard - will crash app") sentry.add_breadcrumb({ message = "Fatal error triggered via keyboard (F key)", @@ -381,8 +359,8 @@ end function love.quit() -- Clean shutdown - logger.info("Love2D application quit") - logger.flush() + sentry.info("Love2D application quit") + sentry.flush() sentry.close() diff --git a/examples/love2d/sentry b/examples/love2d/sentry index eae6842..c0629a9 120000 --- a/examples/love2d/sentry +++ b/examples/love2d/sentry @@ -1 +1 @@ -../../build/sentry \ No newline at end of file +../../src/sentry \ No newline at end of file diff --git a/examples/wrap_demo.lua b/examples/wrap_demo.lua index 0253fbc..4d1253e 100644 --- a/examples/wrap_demo.lua +++ b/examples/wrap_demo.lua @@ -1,7 +1,7 @@ -- Demonstration of sentry.wrap() for automatic error capture -- This shows the recommended way to automatically capture unhandled errors -package.path = "build/?.lua;build/?/init.lua;" .. package.path +package.path = "src/?.lua;src/?/init.lua;" .. package.path local sentry = require("sentry.init") -- Initialize Sentry diff --git a/scripts/dev.lua b/scripts/dev.lua index d91c985..9073d11 100755 --- a/scripts/dev.lua +++ b/scripts/dev.lua @@ -150,6 +150,11 @@ local function run_format() run_command("stylua .", "Formatting Lua code") end +local function test_love2d() + print("TODO") + +end + local function test_rockspec() print("📋 Testing rockspec installation...") @@ -190,6 +195,7 @@ local function show_help() print(" format-check Check code formatting") print(" format Format code with StyLua") print(" test-rockspec Test rockspec installation") + print(" ci-love2d Run Love2D integration tests") print(" clean Clean artifacts") print(" ci Run full CI pipeline (lint, format-check, test, coverage)") print(" help Show this help") @@ -197,6 +203,7 @@ local function show_help() print("Examples:") print(" lua scripts/dev.lua install") print(" lua scripts/dev.lua test") + print(" lua scripts/dev.lua ci-love2d") print(" lua scripts/dev.lua ci") end @@ -226,6 +233,8 @@ elseif command == "format" then run_format() elseif command == "test-rockspec" then test_rockspec() +elseif command == "ci-love2d" then + test_love2d() elseif command == "clean" then clean() elseif command == "ci" then From e37b988cbc945e91354674dc5df513caf2b709dc Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 17:42:34 -0400 Subject: [PATCH 21/23] ssl mac --- scripts/dev.lua | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/dev.lua b/scripts/dev.lua index 9073d11..704947d 100755 --- a/scripts/dev.lua +++ b/scripts/dev.lua @@ -42,7 +42,15 @@ local function install_dependencies() run_command("luarocks install busted", "Installing busted test framework") run_command("luarocks install lua-cjson", "Installing JSON support") run_command("luarocks install luasocket", "Installing socket support") - run_command("luarocks install luasec", "Installing SSL/HTTPS support") + + -- Install luasec with OpenSSL directory if available + local openssl_dir = os.getenv("OPENSSL_DIR") + if openssl_dir then + run_command("luarocks install luasec OPENSSL_DIR=" .. openssl_dir, "Installing SSL/HTTPS support") + else + run_command("luarocks install luasec", "Installing SSL/HTTPS support") + end + run_command("luarocks install luacov", "Installing coverage tool") run_command("luarocks install luacov-reporter-lcov", "Installing LCOV reporter") From 7cb115a9b7e354691ed85d926ca3d41b411932ad Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 17:44:46 -0400 Subject: [PATCH 22/23] linter rules --- .luacheckrc | 1 + .styluaignore | 7 ++----- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/.luacheckrc b/.luacheckrc index 3f19786..5b335b3 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -4,6 +4,7 @@ exclude_files = { ".luarocks/", ".git/", + "examples/love2d/sentry/" -- symlink to 'src' } files["spec/"] = { diff --git a/.styluaignore b/.styluaignore index 5deb064..be69b50 100644 --- a/.styluaignore +++ b/.styluaignore @@ -2,8 +2,5 @@ .luarocks/ .git/ -# Ignore any vendor or external dependencies -vendor/ - -# Ignore specific files if needed (uncomment as needed) -# examples/some-example.lua \ No newline at end of file +# symlink to 'src' +examples/love2d/sentry/ From 6493f004c1b33c435a4e8bdcd67f7490587a76f8 Mon Sep 17 00:00:00 2001 From: Bruno Garcia Date: Sun, 24 Aug 2025 17:51:08 -0400 Subject: [PATCH 23/23] ci --- .github/workflows/love2d.yml | 7 +------ .github/workflows/test.yml | 2 +- scripts/dev.lua | 24 ++++++++++++++++++++++-- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/.github/workflows/love2d.yml b/.github/workflows/love2d.yml index 4aedc1d..33b4110 100644 --- a/.github/workflows/love2d.yml +++ b/.github/workflows/love2d.yml @@ -49,15 +49,10 @@ jobs: - name: Run Love2D tests run: lua scripts/dev.lua ci-love2d - - name: Test example module loading - run: | - cd examples/love2d - LUA_PATH="../../src/?.lua;../../src/?/init.lua;;" lua test_modules.lua - - name: Package Love2D example run: | cd examples/love2d - zip -r sentry-love2d-demo.love . -x "*.DS_Store" "test_modules.lua" + zip -r sentry-love2d-demo.love . -x "*.DS_Store" ls -la sentry-love2d-demo.love - name: Upload Love2D artifacts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f58a914..ac0f8d5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -218,7 +218,7 @@ jobs: cp README.md dist-temp/ || { echo "❌ README.md not found"; exit 1; } # Copy directories (recursively) - cp -r src dist-temp/ || { echo "❌ src directory not found; exit 1; } + cp -r src dist-temp/ || { echo "❌ src directory not found"; exit 1; } cp -r spec dist-temp/ || { echo "❌ spec directory not found"; exit 1; } cp -r examples dist-temp/ || { echo "❌ examples directory not found"; exit 1; } diff --git a/scripts/dev.lua b/scripts/dev.lua index 704947d..db976d0 100755 --- a/scripts/dev.lua +++ b/scripts/dev.lua @@ -176,8 +176,28 @@ local function test_rockspec() os.exit(1) end - -- Test installation - run_command("luarocks make " .. rockspec, "Testing rockspec installation") + -- Try local installation first (safer for CI environments), fallback to system + local success = false + local luarocks_cmd = "luarocks make --local " .. rockspec + local result = os.execute(luarocks_cmd) + + if result == 0 or result == true then + print("✅ Rockspec test passed (local installation)") + success = true + else + -- Fallback to system installation + luarocks_cmd = "luarocks make " .. rockspec + result = os.execute(luarocks_cmd) + if result == 0 or result == true then + print("✅ Rockspec test passed (system installation)") + success = true + end + end + + if not success then + print("❌ Rockspec test failed") + os.exit(1) + end end local function clean()