diff --git a/.gitignore b/.gitignore index fded172..d9cc5c4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ /vendor/bundle/ # macOS -.DS_Store +**/.DS_Store # RSpec /spec/examples.txt @@ -19,3 +19,7 @@ .idea/ *.swp *.swo + +.claude/ +AUDIT_REPORT.md +FIXES_IMPLEMENTED.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea360ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,52 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Security +- Fixed XSS vulnerabilities in example templates by using proper JSON escaping +- Removed API key exposure from client-side example templates +- Added security documentation for safe template usage + +### Fixed +- Removed thread-unsafe global state (`current_context`) to prevent cross-user data contamination +- Added comprehensive exception handling throughout SDK to prevent page crashes +- Added `ready?` checks consistently across all tags and filters +- Fixed regex patterns to support experiment names with hyphens and dots +- Fixed track filter to check `ready?` consistently with other filters +- Fixed Drop methods to return results from track operations +- Added input validation for all Drop methods +- Fixed null reference errors in example templates + +### Added +- Comprehensive logging throughout SDK with configurable logger +- Strict mode option for fail-fast behavior in development/staging +- Better error messages for syntax errors in tags +- Warnings when context is missing or not ready +- Warnings when tracking events are dropped + +### Changed +- Removed global fallback in filters (now requires explicit Drop injection) +- Improved property parsing in TrackTag to handle more complex values +- Changed Drop to use `attr_reader` for cleaner code +- Simplified filter implementations using `with_ready_context` helper + +### Removed +- Removed `ABsmartly::Liquid.current_context` global variable (thread-unsafe) +- Removed duplicate `liquid` dependency from Gemfile +- Removed unused `rack-test` dependency from Gemfile +- Removed `.DS_Store` files from repository + +## [1.0.0] - Initial Release + +### Added +- Initial release of ABsmartly Liquid SDK +- Support for Shopify Liquid templates +- Filters for treatment assignment and variable resolution +- Tags for treatment blocks and event tracking +- Drop object for template integration +- Example Shopify theme templates diff --git a/Gemfile b/Gemfile index dd71e7b..b1b6c93 100644 --- a/Gemfile +++ b/Gemfile @@ -2,11 +2,8 @@ source 'https://rubygems.org' gemspec -gem 'liquid', '~> 5.0' - group :development, :test do gem 'rspec', '~> 3.12' gem 'webmock', '~> 3.18' - gem 'rack-test', '~> 2.0' gem 'simplecov', '~> 0.22' end diff --git a/Gemfile.lock b/Gemfile.lock index 91d7e0c..de274e6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -40,9 +40,6 @@ GEM net-http (0.9.1) uri (>= 0.11.1) public_suffix (7.0.2) - rack (3.2.4) - rack-test (2.2.0) - rack (>= 1.3) rake (13.3.1) rexml (3.4.4) rspec (3.13.2) @@ -78,8 +75,6 @@ PLATFORMS DEPENDENCIES absmartly-liquid-sdk! bundler (~> 2.0) - liquid (~> 5.0) - rack-test (~> 2.0) rake (~> 13.0) rspec (~> 3.12) simplecov (~> 0.22) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d6ea6a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Support. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 ABsmartly + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index 3fdd870..390e0e3 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,21 @@ -# ABsmartly SDK for Shopify Liquid +# ABsmartly Liquid SDK -A/B Smartly SDK for Shopify Liquid templating language with server-side rendering support. - -## Overview - -This SDK enables A/B testing in Shopify stores using Liquid templates. It provides Liquid filters, tags, and drops (objects) that integrate with ABsmartly's experimentation platform. - -**Architecture:** -- Ruby backend SDK (handles HTTP, variant assignment, hashing) -- Liquid filters and tags for template integration -- Server-side context initialization and caching -- Shopify theme integration +A/B testing SDK for [Shopify Liquid](https://shopify.github.io/liquid/) templating language and [Jekyll](https://jekyllrb.com/) static site generator. This SDK brings the power of ABsmartly's experimentation platform to server-side rendered templates. ## Compatibility -- Shopify Liquid templates -- Ruby 2.7+ (Shopify backend) -- Works with Shopify themes and apps - -## Installation - -### For Shopify Themes - -1. Add the ABsmartly Liquid SDK files to your theme: +The ABsmartly Liquid SDK is compatible with: -``` -assets/ - absmartly.js # Client-side tracking (optional) -snippets/ - absmartly-init.liquid # Initialization snippet - absmartly-tracking.liquid # Tracking snippet -``` +- **Ruby**: Version 3.0 and later +- **Liquid**: Version 5.0 and later +- **Shopify Themes**: All modern Shopify themes +- **Jekyll**: Version 4.0 and later -2. Add initialization to your `theme.liquid`: +**Note on Naming:** This SDK uses the module name `ABsmartly::Liquid` (with namespace) to avoid clashing with the Ruby SDK's main module (`Absmartly`). When requiring the gem, use `require 'absmartly/liquid'`. -```liquid -{% render 'absmartly-init', - session_id: customer.id | default: request.cookie.session_id, - customer_id: customer.id -%} -``` - -### For Shopify Apps +## Installation -Add to your Gemfile: +Add to your `Gemfile`: ```ruby gem 'absmartly-liquid-sdk' @@ -55,586 +27,420 @@ Then run: bundle install ``` -## Quick Start - -### 1. Initialize Context (Server-Side) - -In your Shopify app controller or theme: - -```ruby -# Initialize SDK -sdk = ABSmartly::SDK.new( - endpoint: 'https://your-endpoint.absmartly.io/v1', - api_key: ENV['ABSMARTLY_API_KEY'], - application: 'shopify-store', - environment: 'production' -) - -# Create context -@absmartly_context = sdk.create_context( - units: { - session_id: session[:id], - customer_id: current_customer&.id - } -) - -# Wait for ready -@absmartly_context.ready +For Jekyll sites, also add to your `_config.yml`: -# Make context available to Liquid -@absmartly_data = { - 'context_data' => @absmartly_context.data.to_json, - 'units' => @absmartly_context.get_units -} +```yaml +plugins: + - absmartly-liquid-sdk ``` -### 2. Use in Liquid Templates - -#### Treatment Assignment - -```liquid -{% assign variant = 'exp_button_color' | absmartly_treatment %} - -{% if variant == 0 %} - -{% elsif variant == 1 %} - -{% endif %} -``` +## Getting Started -#### Variable Access +Please follow the [installation](#installation) instructions before trying the following code. -```liquid -{% assign button_text = 'button_text' | absmartly_variable: 'Buy Now' %} - -``` +### Initialization -#### Peek (Without Tracking) +This example assumes an API Key, an Application, and an Environment have been created in the ABsmartly web console. -```liquid -{% assign variant = 'exp_button_color' | absmartly_peek %} -``` - -#### Goal Tracking +```ruby +require 'absmartly/liquid' -```liquid -{% absmartly_track 'add_to_cart', amount: product.price, quantity: 1 %} +sdk = ABSmartly::SDK.new( + endpoint: 'https://your-company.absmartly.io/v1', + api_key: ENV['ABSMARTLY_API_KEY'], + application: 'my-shopify-store', + environment: 'production' +) ``` -Or use the filter: +#### With Optional Parameters -```liquid -{{ 'purchase' | absmartly_track: amount: order.total_price }} +```ruby +sdk = ABSmartly::SDK.new( + endpoint: 'https://your-company.absmartly.io/v1', + api_key: ENV['ABSMARTLY_API_KEY'], + application: 'my-shopify-store', + environment: 'production', + retries: 3, + timeout: 5000 +) ``` -### 3. Client-Side Tracking (Optional) +**SDK Options** -For tracking after page load: +| Option | Type | Required? | Default | Description | +| :----------- | :-------- | :-------: | :-----: | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| endpoint | `String` | ✅ | `nil` | The URL to your API endpoint. Most commonly `"https://your-company.absmartly.io/v1"` | +| api_key | `String` | ✅ | `nil` | Your API key which can be found on the Web Console. | +| environment | `String` | ✅ | `nil` | The environment of the platform where the SDK is installed. Environments are created on the Web Console. | +| application | `String` | ✅ | `nil` | The name of the application where the SDK is installed. Applications are created on the Web Console. | +| retries | `Integer` | ❌ | `5` | Maximum number of HTTP retries for failed requests | +| timeout | `Integer` | ❌ | `3000` | HTTP timeout in milliseconds | +| event_logger | `Proc` | ❌ | `nil` | Custom event logger callback (see Advanced section) | -```liquid - -``` +## Creating a New Context -## Liquid Filters +### Synchronously -### `absmartly_treatment` +```ruby +context = sdk.create_context( + units: { + session_id: session[:id], + customer_id: current_customer&.id + } +) -Get variant and track exposure. +context.ready -```liquid -{% assign variant = 'exp_test' | absmartly_treatment %} +drop = ABSmartly::Liquid::Drop.new(context) ``` -**Returns:** Variant number (0, 1, 2, ...) +### With Pre-fetched Data -**Side effect:** Tracks exposure event +For better performance, pre-fetch context data and reuse it to avoid an additional round-trip. -### `absmartly_peek` +```ruby +data = sdk.get_client.get_context( + units: { session_id: session[:id] } +) -Get variant without tracking exposure. +context = sdk.create_context_with( + { units: { session_id: session[:id] } }, + data +) -```liquid -{% assign variant = 'exp_test' | absmartly_peek %} +drop = ABSmartly::Liquid::Drop.new(context) ``` -**Returns:** Variant number (0, 1, 2, ...) - -**Side effect:** None +### Refreshing the Context with Fresh Experiment Data -### `absmartly_variable` +For long-running contexts, the context is usually created once when the application is first started. However, any experiments started after the context was created will not be triggered. -Get variable value and track exposure. +```ruby +context = sdk.create_context( + units: { session_id: session[:id] }, + refresh_period: 4 * 60 * 60 * 1000 +) -```liquid -{% assign value = 'button_color' | absmartly_variable: 'blue' %} +# Or refresh manually +context.refresh ``` -**Parameters:** -- First argument (pipe input): Variable key -- Second argument: Default value +### Setting Extra Units -**Returns:** Variable value or default +You can add additional units to a context. This may be used, for example, when a user logs in to your application. -**Side effect:** Tracks exposure event +**Note:** You cannot override an already set unit type as that would be a change of identity. In this case, you must create a new context instead. -### `absmartly_peek_variable` - -Get variable value without tracking exposure. - -```liquid -{% assign value = 'button_color' | absmartly_peek_variable: 'blue' %} +```ruby +context.set_unit('db_user_id', '1000013') ``` -**Returns:** Variable value or default - -**Side effect:** None +## Basic Usage -### `absmartly_track` +### Treatment Selection with Filters -Track goal achievement. +Use the `absmartly_treatment` filter to select a treatment variant: ```liquid -{{ 'purchase' | absmartly_track: amount: order.total_price, items: order.line_items.size }} -``` - -**Parameters:** -- First argument (pipe input): Goal name -- Named parameters: Goal properties (must be numeric) - -**Returns:** Empty string - -**Side effect:** Queues goal event for publishing - -### `absmartly_custom_field` - -Get custom field value. +{% assign variant = 'exp_button_color' | absmartly_treatment %} -```liquid -{% assign metadata = 'exp_test' | absmartly_custom_field: 'metadata' %} +{% if variant == 0 %} + +{% elsif variant == 1 %} + +{% endif %} ``` -**Returns:** Parsed field value based on type - -## Liquid Tags +### Treatment Selection with Block Tag -### `absmartly_treatment` - -Block tag for treatment assignment. +Use the block tag for cleaner syntax with a local `variant` variable: ```liquid {% absmartly_treatment 'exp_button_color' %} {% if variant == 0 %} - + {% elsif variant == 1 %} - + {% endif %} {% endabsmartly_treatment %} ``` -Inside the block, `variant` variable is available. - -### `absmartly_track` +### Treatment Variables -Track goal with properties. +Variables allow you to configure experiment variations without code changes: ```liquid -{% absmartly_track 'purchase', amount: order.total_price, items: order.line_items.size %} +{% assign button_text = 'checkout_button_text' | absmartly_variable: 'Checkout' %} +{% assign button_color = 'checkout_button_color' | absmartly_variable: 'blue' %} + + + {{ button_text }} + ``` -## Liquid Drops (Objects) +### Peek at Treatment Variants -ABsmartly context is available as `absmartly` object in Liquid: +Although generally not recommended, it is sometimes necessary to peek at a treatment without triggering an exposure. ```liquid -{{ absmartly.experiments }} -{{ absmartly.pending }} -{{ absmartly.ready }} +{% assign variant = 'exp_feature' | absmartly_peek %} ``` -### Properties - -- `ready` (boolean) - Whether context is ready -- `failed` (boolean) - Whether context failed to load -- `finalized` (boolean) - Whether context is finalized -- `experiments` (array) - List of experiment names -- `pending` (number) - Count of pending events - -### Methods +#### Peeking at Variables ```liquid -{{ absmartly.treatment('exp_test') }} -{{ absmartly.peek('exp_test') }} -{{ absmartly.variable('button_color', 'blue') }} -{{ absmartly.peek_variable('button_color', 'blue') }} -{{ absmartly.custom_field('exp_test', 'metadata') }} -{{ absmartly.track('goal_name', properties) }} +{% assign color = 'button_color' | absmartly_peek_variable: 'blue' %} ``` -## Server-Side Integration +### Overriding Treatment Variants -### Shopify App Example +During development, force specific variants: ```ruby -class ProductsController < ApplicationController - before_action :init_absmartly - - def show - @product = Product.find(params[:id]) - - # Get variant for this user - variant = @absmartly.treatment('exp_product_layout') - - # Pass to view - @layout_variant = variant +context.override('exp_test', 1) - # Data for Liquid - @absmartly_liquid = ABSmartly::LiquidDrop.new(@absmartly) - end - - private - - def init_absmartly - @absmartly = $absmartly_sdk.create_context( - units: { - session_id: session[:id], - customer_id: current_customer&.id - } - ) - @absmartly.ready - end -end +context.overrides({ + 'exp_test' => 1, + 'exp_another' => 0 +}) ``` -### Theme Liquid Integration +### Tracking Goals + +Track goal achievement with properties: ```liquid - -{% capture session_id %}{{ request.cookie['_shopify_s'] }}{% endcapture %} - -{% render 'absmartly-init', - endpoint: 'https://your-endpoint.absmartly.io/v1', - api_key: 'your-api-key', - session_id: session_id, - customer_id: customer.id -%} - - -{% assign layout_variant = 'exp_product_layout' | absmartly_treatment %} - -{% if layout_variant == 0 %} - {% render 'product-layout-control' %} -{% elsif layout_variant == 1 %} - {% render 'product-layout-treatment' %} -{% endif %} + +{{ 'purchase' | absmartly_track: amount: order.total_price, items: order.line_items.size }} + + +{% absmartly_track 'add_to_cart', product_id: product.id, price: product.price %} ``` -## Advanced Usage +## Advanced -### Pre-fetching Context Data +### Context Attributes -For better performance, pre-fetch context data server-side: +Add metadata for audience targeting: ```ruby -# In controller -data = sdk.get_client.get_context(units: { session_id: session[:id] }) +context.attributes({ + user_agent: request.user_agent, + customer_age: 'new_customer', + account_type: 'premium' +}) +``` -# Create context with data (no HTTP call) -@absmartly = sdk.create_context_with( - { units: { session_id: session[:id] } }, - data -) +### Publishing Pending Data + +Ensure all events are published before proceeding: -# Pass to Liquid -assigns['absmartly'] = ABSmartly::LiquidDrop.new(@absmartly) +```ruby +context.publish ``` -### Caching Context Data +### Finalizing -```ruby -# Cache context data for session -cache_key = "absmartly:#{session[:id]}" -data = Rails.cache.fetch(cache_key, expires_in: 5.minutes) do - sdk.get_client.get_context(units: { session_id: session[:id] }) -end +The `close` method will ensure all events have been published to the ABsmartly collector, like `publish`, and will also "seal" the context, preventing any further events from being tracked. -@absmartly = sdk.create_context_with({ units: { session_id: session[:id] } }, data) +```ruby +context.close ``` ### Custom Event Logger +Monitor SDK events for debugging, analytics, or integrating with other systems. + ```ruby -class ShopifyEventLogger - def handle_event(context, event_name, data) - case event_name - when 'exposure' - # Log to Shopify analytics - Analytics.track('absmartly_exposure', data) - when 'goal' - # Log to your tracking system - Tracking.event('absmartly_goal', data) - when 'error' - # Log errors - Rails.logger.error("ABsmartly error: #{data}") - end +event_logger = ->(context, event_name, data) { + case event_name + when 'exposure' + Rails.logger.info "ABsmartly exposure: #{data[:name]}" + when 'goal' + Rails.logger.info "ABsmartly goal: #{data[:name]}" + when 'error' + Rails.logger.error "ABsmartly error: #{data}" + when 'ready' + Rails.logger.info "ABsmartly context ready" + when 'refresh' + Rails.logger.info "ABsmartly context refreshed" + when 'publish' + Rails.logger.info "ABsmartly events published" + when 'close' + Rails.logger.info "ABsmartly context closed" end -end +} sdk = ABSmartly::SDK.new( endpoint: ENV['ABSMARTLY_ENDPOINT'], api_key: ENV['ABSMARTLY_API_KEY'], - application: 'shopify-store', + application: 'my-store', environment: Rails.env, - event_logger: ShopifyEventLogger.new + event_logger: event_logger ) ``` -### Publishing Events +**Event Types** -Events are automatically published, but you can manually publish: +| Event | When | Data | +| :--------- | :------------------------------------------- | :----------------------------------- | +| `ready` | Context turns ready | Context initialization data | +| `refresh` | `refresh()` succeeds | Refreshed context data | +| `publish` | `publish()` succeeds | Published events | +| `exposure` | `treatment()` succeeds on first exposure | Exposure data | +| `goal` | `track()` succeeds | Goal data | +| `close` | `close()` succeeds the first time | `nil` | +| `error` | Error occurs | Error object | -```ruby -# In controller after action -@absmartly.publish -``` - -Or finalize on session end: - -```ruby -# In ApplicationController -after_action :finalize_absmartly - -def finalize_absmartly - @absmartly&.finalize -end -``` +## Liquid API Reference -## Examples +### Filters -### Product Page A/B Test +| Filter | Description | Returns | +| :------------------------ | :---------------------------------------------- | :------------------------------- | +| `absmartly_treatment` | Get treatment variant and track exposure | Integer variant number (0, 1, ...) | +| `absmartly_peek` | Get treatment variant without tracking exposure | Integer variant number | +| `absmartly_variable` | Get variable value and track exposure | Variable value or default | +| `absmartly_peek_variable` | Get variable value without tracking exposure | Variable value or default | +| `absmartly_custom_field` | Get custom field value for an experiment | Parsed field value | +| `absmartly_track` | Track goal achievement with properties | Empty string | -```liquid - -{% assign variant = 'exp_product_images' | absmartly_treatment %} +### Tags -{% if variant == 0 %} - - {{ product.title }} -{% elsif variant == 1 %} - - -{% endif %} +| Tag | Description | +| :---------------------------- | :---------------------------------------------- | +| `{% absmartly_treatment %}` | Block tag for treatment with local `variant` variable | +| `{% absmartly_track %}` | Tag for tracking goals with properties | - -
- -
-``` +### Drop Object -### Checkout Button Text +The `absmartly` drop object is available in templates when injected via `ABSmartly::Liquid::Drop.new(context)`: ```liquid -{% assign button_text = 'checkout_button_text' | absmartly_variable: 'Checkout' %} -{% assign button_color = 'checkout_button_color' | absmartly_variable: 'blue' %} - - - {{ button_text }} - -``` - -### Free Shipping Threshold - -```liquid -{% assign free_shipping_threshold = 'free_shipping_threshold' | absmartly_variable: 50 %} - -{% if cart.total_price >= free_shipping_threshold %} -
- 🎉 You qualify for free shipping! -
-{% else %} - {% assign remaining = free_shipping_threshold | minus: cart.total_price %} -
- Add ${{ remaining }} more for free shipping -
+{% if absmartly.ready %} + {% endif %} -``` - -### Conversion Tracking -```liquid - -{% if first_time_accessed %} - {% absmartly_track 'purchase', - amount: order.total_price, - items: order.line_items.size, - revenue: order.subtotal_price - %} -{% endif %} +{% for exp in absmartly.experiments %} +
  • {{ exp }}
  • +{% endfor %} ``` -### Feature Flags - -```liquid -{% assign show_new_feature = 'feature_new_search' | absmartly_treatment %} +## Platform-Specific Examples -{% if show_new_feature == 1 %} - {% render 'search-v2' %} -{% else %} - {% render 'search-v1' %} -{% endif %} -``` +### Using with Shopify / Rails -## Testing +```ruby +class ApplicationController < ActionController::Base + before_action :init_absmartly + after_action :close_absmartly -### Unit Tests + private -```ruby -require 'minitest/autorun' -require 'absmartly/liquid' + def init_absmartly + $absmartly_sdk ||= ABSmartly::SDK.new( + endpoint: ENV['ABSMARTLY_ENDPOINT'], + api_key: ENV['ABSMARTLY_API_KEY'], + application: 'my-shopify-store', + environment: Rails.env + ) -class ABSmartlyLiquidTest < Minitest::Test - def setup - @sdk = ABSmartly::SDK.new( - endpoint: 'https://sandbox.absmartly.io/v1', - api_key: 'test-key', - application: 'test', - environment: 'test' + @context = $absmartly_sdk.create_context( + units: { + session_id: session[:id], + customer_id: current_customer&.id + } ) - @context = @sdk.create_context(units: { session_id: 'test123' }) @context.ready - end - def test_treatment_filter - template = Liquid::Template.parse("{{ 'exp_test' | absmartly_treatment }}") - output = template.render('absmartly' => ABSmartly::LiquidDrop.new(@context)) + @context.attributes({ + user_agent: request.user_agent, + customer_logged_in: current_customer.present? + }) - assert_match /^[0-9]+$/, output + @absmartly_drop = ABSmartly::Liquid::Drop.new(@context) + rescue => e + Rails.logger.error "ABsmartly initialization failed: #{e.message}" + @absmartly_drop = nil end - def test_variable_filter - template = Liquid::Template.parse("{{ 'button_color' | absmartly_variable: 'blue' }}") - output = template.render('absmartly' => ABSmartly::LiquidDrop.new(@context)) - - assert_includes ['blue', 'red', 'green'], output + def close_absmartly + @context&.close end end ``` -### Integration Tests - -See `test/integration_test.rb` for full integration tests. - -## Performance Considerations - -### Caching - -- Cache context data per session (5-10 minutes) -- Use Redis for distributed caching -- Pre-fetch data server-side to avoid blocking Liquid rendering - -### Event Publishing - -- Events are batched and published asynchronously -- Configure `publish_delay` to batch more events -- Use background jobs for publishing on high-traffic stores - -### Liquid Rendering - -- Minimize A/B test logic in templates -- Pre-calculate variants server-side when possible -- Use fragment caching for expensive A/B test blocks - -## Troubleshooting - -### Context Not Ready - -If `absmartly.ready` is false: +In your template: ```liquid {% if absmartly.ready %} - {% assign variant = 'exp_test' | absmartly_treatment %} + {% assign header_color = 'header_color' | absmartly_variable: 'blue' %} +
    + {% render 'header' %} +
    {% else %} - +
    + {% render 'header' %} +
    {% endif %} ``` -### Events Not Publishing - -Check event logger configuration and network connectivity: +### Using with Jekyll ```ruby -# Enable debug logging -sdk = ABSmartly::SDK.new( - endpoint: ENV['ABSMARTLY_ENDPOINT'], - api_key: ENV['ABSMARTLY_API_KEY'], - application: 'shopify-store', - environment: Rails.env, - event_logger: ->(ctx, event, data) { Rails.logger.debug "ABsmartly: #{event} - #{data}" } -) -``` - -### Variant Mismatch - -Ensure units are consistent: - -```ruby -# Use same session ID everywhere -session_id = session[:id] || SecureRandom.uuid -session[:id] = session_id +# _plugins/absmartly.rb +require 'absmartly/liquid' -@absmartly = sdk.create_context(units: { session_id: session_id }) +Jekyll::Hooks.register :site, :pre_render do |site| + sdk = ABSmartly::SDK.new( + endpoint: ENV['ABSMARTLY_ENDPOINT'], + api_key: ENV['ABSMARTLY_API_KEY'], + application: 'jekyll-site', + environment: ENV['JEKYLL_ENV'] || 'development' + ) + + context = sdk.create_context( + units: { site_id: site.config['url'] } + ) + context.ready + + drop = ABSmartly::Liquid::Drop.new(context) + site.config['absmartly'] = drop +end ``` -## API Reference - -See [API.md](./API.md) for complete API documentation. - -## Best Practices - -1. **Initialize once per request** - Create context in controller/before_action -2. **Use consistent units** - Same session_id/customer_id throughout request -3. **Cache context data** - Reduce HTTP calls to ABsmartly -4. **Track conversions** - Use `absmartly_track` on important actions -5. **Test fallbacks** - Handle context not ready gracefully -6. **Monitor performance** - Log slow requests, optimize caching - -## Examples Repository +In your Jekyll template: -See [examples/](./examples/) for complete Shopify theme examples: -- Product page A/B tests -- Checkout flow optimization -- Homepage layout variations -- Feature flags -- Conversion tracking - -## Contributing - -See [CONTRIBUTING.md](./CONTRIBUTING.md) - -## License +```liquid +{% assign hero_variant = 'exp_homepage_hero' | absmartly_treatment %} -Apache License 2.0 +{% if hero_variant == 0 %} + {% include hero-v1.html %} +{% elsif hero_variant == 1 %} + {% include hero-v2.html %} +{% endif %} +``` -## About ABsmartly +## About A/B Smartly -**ABsmartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. +**A/B Smartly** is the leading provider of state-of-the-art, on-premises, full-stack experimentation platforms for engineering and product teams that want to confidently deploy features as fast as they can develop them. +A/B Smartly's real-time analytics helps engineering and product teams ensure that new features will improve the customer experience without breaking or degrading performance and/or business metrics. -### Links +### Have a look at our growing list of clients and SDKs: -- [Documentation](https://docs.absmartly.com) -- [JavaScript SDK](https://github.com/absmartly/javascript-sdk) -- [Python SDK](https://github.com/absmartly/python3-sdk) -- [Ruby SDK](https://github.com/absmartly/ruby-sdk) -- [Java SDK](https://github.com/absmartly/java-sdk) +- [JavaScript SDK](https://www.github.com/absmartly/javascript-sdk) +- [Java SDK](https://www.github.com/absmartly/java-sdk) +- [PHP SDK](https://www.github.com/absmartly/php-sdk) +- [Swift SDK](https://www.github.com/absmartly/swift-sdk) +- [Vue2 SDK](https://www.github.com/absmartly/vue2-sdk) +- [Vue3 SDK](https://www.github.com/absmartly/vue3-sdk) +- [React SDK](https://www.github.com/absmartly/react-sdk) +- [Python3 SDK](https://www.github.com/absmartly/python3-sdk) +- [Go SDK](https://www.github.com/absmartly/go-sdk) +- [Ruby SDK](https://www.github.com/absmartly/ruby-sdk) +- [.NET SDK](https://www.github.com/absmartly/dotnet-sdk) +- [Dart SDK](https://www.github.com/absmartly/dart-sdk) +- [Flutter SDK](https://www.github.com/absmartly/flutter-sdk) +- [Liquid SDK](https://www.github.com/absmartly/liquid-sdk) (this package) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4f4d984 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,241 @@ +# Security Best Practices + +This document outlines security considerations when using the ABsmartly Liquid SDK. + +## Critical Security Requirements + +### 1. Never Expose API Keys Client-Side + +**CRITICAL:** Never include your ABsmartly API key in client-side JavaScript or Liquid templates that render in the browser. + +**Bad Example:** +```liquid + +``` + +**Why this is dangerous:** +- Anyone viewing your page source can read the API key +- If the key has write/admin permissions, attackers can manipulate experiments +- Attackers can push fake tracking events +- Attackers can read experiment configurations + +**Correct Approach:** +- Use the SDK server-side only in Liquid templates +- Pre-fetch experiment data server-side +- If you need client-side tracking, use a separate read-only public key (if your ABsmartly plan supports it) + +### 2. Always Use JSON Escaping in JavaScript Contexts + +When interpolating Liquid variables into ` +``` + +**Why this is dangerous:** +- If `shop.name` contains `'; alert(document.cookie); //`, it breaks out of the string +- If `shop.name` contains ``, it breaks out of the script tag entirely +- Attackers can execute arbitrary JavaScript in users' browsers + +**Correct Approach:** +```liquid + +``` + +Or even better, build the entire object server-side: +```liquid +{% capture config_data %} +{ + "shopName": {{ shop.name | json }}, + "sessionId": {{ session_id | json }} +} +{% endcapture %} + + +``` + +### 3. Validate User-Controlled Data + +Never pass user-controlled data directly to experiment names, goal names, or properties without validation. + +**Example:** +```ruby +# In your Shopify app or controller +experiment_name = params[:experiment] # User-controlled input + +# Validate against whitelist +allowed_experiments = ['exp_header_test', 'exp_button_color', 'exp_pricing'] +unless allowed_experiments.include?(experiment_name) + experiment_name = 'default_experiment' +end + +# Now safe to use in template +assigns['experiment_name'] = experiment_name +``` + +### 4. Content Security Policy + +Implement Content Security Policy (CSP) headers to mitigate XSS impact: + +``` +Content-Security-Policy: script-src 'self' 'unsafe-inline' https://cdn.absmartly.com; +``` + +Note: The Liquid SDK renders templates server-side, so inline scripts are common. Consider using nonces for better CSP: + +```liquid + +``` + +## Thread Safety + +### Context Injection Required + +The SDK **requires** explicit context injection via the Drop object. The global fallback (`ABsmartly::Liquid.current_context`) has been removed as it was thread-unsafe and caused cross-user data contamination. + +**Correct Setup:** + +```ruby +# In your Shopify app or Rails controller +def index + # Create context per request + context = absmartly_sdk.create_context( + units: { session_id: session.id } + ) + + # Inject via Drop + @absmartly = ABsmartly::Liquid::Drop.new(context) + + # Render template with assigns + template = Liquid::Template.parse(liquid_template_source) + template.render('absmartly' => @absmartly) +end +``` + +**Never:** +- Use a shared global context across requests +- Reuse context objects between requests +- Set `ABsmartly::Liquid.current_context` (removed — use Drop injection instead) + +## Privacy Considerations + +### PII in Unit Identifiers + +Be careful about what data you use as unit identifiers: + +**Recommended:** +```ruby +units = { + session_id: SecureRandom.uuid, # Anonymous session token + anonymous_id: cookies[:anonymous_id] +} +``` + +**Avoid:** +```ruby +units = { + email: user.email, # PII + ip_address: request.remote_ip, # PII + full_name: user.name # PII +} +``` + +### GDPR/CCPA Compliance + +If you use customer IDs or other identifiers: +1. Disclose A/B testing in your privacy policy +2. Provide opt-out mechanisms +3. Handle data deletion requests +4. Don't expose unit IDs in client-side code + +## Error Handling Modes + +The SDK provides two error handling modes: + +### Graceful Mode (Default) + +Errors are logged but don't crash page rendering: + +```ruby +ABsmartly::Liquid.strict_mode = false # Default +``` + +Behavior: +- Missing context → returns control variant (0) + logs warning +- SDK errors → returns safe defaults + logs error +- Pages always render successfully + +Use for: **Production environments** + +### Strict Mode + +Errors raise exceptions immediately: + +```ruby +ABsmartly::Liquid.strict_mode = true +``` + +Behavior: +- Missing context → raises exception +- SDK errors → propagates exception +- Pages crash if SDK misconfigured + +Use for: **Development and staging environments** to catch configuration issues early + +## Logging Configuration + +Configure logging to monitor SDK health: + +```ruby +# Use your application's logger +ABsmartly::Liquid.logger = Rails.logger + +# Or custom logger +ABsmartly::Liquid.logger = Logger.new('log/absmartly.log') +ABsmartly::Liquid.logger.level = Logger::WARN + +# Disable logging +ABsmartly::Liquid.logger = Logger.new('/dev/null') +``` + +**Monitor these warnings in production:** +- "ABsmartly context missing" - indicates Drop not injected +- "ABsmartly context not ready" - indicates SDK initialization failure +- "event dropped" - indicates lost tracking data + +## Example Templates Security Review + +The example templates in `examples/shopify-theme/` have been updated to follow these security practices. Review them before using in production. + +Key changes: +- All JavaScript interpolations use `| json` filter +- API keys removed from client-side code +- Null checks added for DOM operations +- Proper type validation for numeric values + +## Security Reporting + +If you discover a security vulnerability in this SDK, please report it to: +- Email: security@absmartly.com +- Do not open public GitHub issues for security vulnerabilities + +## References + +- [OWASP XSS Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html) +- [Shopify Liquid Security](https://shopify.dev/docs/api/liquid) +- [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) diff --git a/examples/shopify-theme/snippets/absmartly-init.liquid b/examples/shopify-theme/snippets/absmartly-init.liquid index e79719c..25c2e26 100644 --- a/examples/shopify-theme/snippets/absmartly-init.liquid +++ b/examples/shopify-theme/snippets/absmartly-init.liquid @@ -1,6 +1,9 @@ {% comment %} ABsmartly Context Initialization Snippet + SECURITY WARNING: This snippet is for SERVER-SIDE use only. + Never expose API keys or credentials client-side. + Usage: {% render 'absmartly-init', session_id: customer.id | default: request.cookie.session_id, @@ -9,19 +12,28 @@ This snippet initializes the ABsmartly context with server-side data and makes it available to all other Liquid templates. + + IMPORTANT: All values are properly JSON-escaped to prevent XSS attacks. {% endcomment %} +{% comment %} + Build config object server-side with proper escaping +{% endcomment %} +{% capture config_data %} +{ + "endpoint": {{ settings.absmartly_endpoint | json }}, + "application": {{ shop.name | json }}, + "environment": {{ settings.absmartly_environment | default: 'production' | json }}, + "units": { + {% if session_id %}"session_id": {{ session_id | json }}{% if customer_id %},{% endif %}{% endif %} + {% if customer_id %}"customer_id": {{ customer_id | json }}{% endif %} + } +} +{% endcapture %} + diff --git a/examples/shopify-theme/snippets/absmartly-tracking.liquid b/examples/shopify-theme/snippets/absmartly-tracking.liquid index 52101ed..8e3ef9e 100644 --- a/examples/shopify-theme/snippets/absmartly-tracking.liquid +++ b/examples/shopify-theme/snippets/absmartly-tracking.liquid @@ -7,22 +7,34 @@ product_id: product.id, collection_id: collection.id %} + + SECURITY: All values are properly JSON-escaped to prevent XSS attacks. + All numeric values are validated to ensure they are actually numbers. +{% endcomment %} + +{% comment %} + Build event data object server-side with proper type checking and escaping {% endcomment %} +{% capture event_fields %} + "event": {{ event | json }} + {% if product_id %}, "product_id": {{ product_id | json }}{% endif %} + {% if collection_id %}, "collection_id": {{ collection_id | json }}{% endif %} + {% if variant_id %}, "variant_id": {{ variant_id | json }}{% endif %} + {% if price %}, "price": {{ price | json }}{% endif %} + {% if quantity %}, "quantity": {{ quantity | json }}{% endif %} + , "timestamp": null +{% endcapture %} +{% capture event_data %} +{ {{ event_fields | strip }} } +{% endcapture %} diff --git a/examples/shopify-theme/templates/product.liquid b/examples/shopify-theme/templates/product.liquid index 9845ba5..baba84c 100644 --- a/examples/shopify-theme/templates/product.liquid +++ b/examples/shopify-theme/templates/product.liquid @@ -73,12 +73,18 @@ {% comment %} Add to cart tracking {% endcomment %} diff --git a/lib/absmartly/liquid.rb b/lib/absmartly/liquid.rb index 19ebf53..1b33ddd 100644 --- a/lib/absmartly/liquid.rb +++ b/lib/absmartly/liquid.rb @@ -1,13 +1,18 @@ require 'liquid' require 'absmartly' +require 'logger' + module ABsmartly module Liquid + autoload :Logging, 'absmartly/liquid/logging' autoload :Drop, 'absmartly/liquid/drop' autoload :Filters, 'absmartly/liquid/filters' autoload :Tags, 'absmartly/liquid/tags' class << self + attr_accessor :logger + attr_accessor :strict_mode attr_accessor :current_context def register_filters @@ -24,6 +29,10 @@ def register_all register_tags end end + + self.logger = Logger.new($stdout) + self.logger.level = Logger::WARN + self.strict_mode = false end end diff --git a/lib/absmartly/liquid/drop.rb b/lib/absmartly/liquid/drop.rb index 10d9d3d..5eb718c 100644 --- a/lib/absmartly/liquid/drop.rb +++ b/lib/absmartly/liquid/drop.rb @@ -1,69 +1,154 @@ +require_relative 'logging' + module ABsmartly module Liquid class Drop < ::Liquid::Drop + include ABsmartly::Liquid::Logging + attr_reader :absmartly_context + def initialize(absmartly_context) @absmartly_context = absmartly_context end def ready @absmartly_context.ready? + rescue StandardError => e + log_error("Error checking ready state: #{e.message}") + raise if ABsmartly::Liquid.strict_mode + false end + alias ready? ready + def failed @absmartly_context.failed? + rescue StandardError => e + log_error("Error checking failed state: #{e.message}") + raise if ABsmartly::Liquid.strict_mode + false end def closed @absmartly_context.closed? + rescue StandardError => e + log_error("Error checking closed state: #{e.message}") + raise if ABsmartly::Liquid.strict_mode + false end alias finalized closed def experiments @absmartly_context.experiments + rescue StandardError => e + log_error("Error retrieving experiments: #{e.message}") + raise if ABsmartly::Liquid.strict_mode + [] end def pending @absmartly_context.pending_count + rescue StandardError => e + log_error("Error retrieving pending count: #{e.message}") + raise if ABsmartly::Liquid.strict_mode + 0 end def treatment(experiment_name) + validate_experiment_name(experiment_name) @absmartly_context.treatment(experiment_name) + rescue StandardError => e + log_error("Error in treatment for '#{experiment_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + 0 end def peek(experiment_name) + validate_experiment_name(experiment_name) @absmartly_context.peek_treatment(experiment_name) + rescue StandardError => e + log_error("Error in peek for '#{experiment_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + 0 end def variable(key, default_value) + validate_variable_key(key) @absmartly_context.variable_value(key, default_value) + rescue StandardError => e + log_error("Error in variable for '#{key}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + default_value end def peek_variable(key, default_value) + validate_variable_key(key) @absmartly_context.peek_variable_value(key, default_value) + rescue StandardError => e + log_error("Error in peek_variable for '#{key}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + default_value end def custom_field(experiment_name, field_name) + validate_experiment_name(experiment_name) + validate_field_name(field_name) @absmartly_context.custom_field_value(experiment_name, field_name) + rescue StandardError => e + log_error("Error in custom_field for '#{experiment_name}.#{field_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + nil end def track(goal_name, properties = nil) + validate_goal_name(goal_name) @absmartly_context.track(goal_name, properties) + rescue StandardError => e + log_error("Error in track for '#{goal_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode nil end def data @absmartly_context.data + rescue StandardError => e + log_error("Error retrieving context data: #{e.message}") + raise if ABsmartly::Liquid.strict_mode + {} end def units - @absmartly_context.units + @absmartly_context.units.dup + rescue StandardError => e + log_error("Error retrieving units: #{e.message}") + raise if ABsmartly::Liquid.strict_mode + {} + end + + private + + def validate_non_empty_string(value, label) + return if value.is_a?(String) && !value.empty? + + raise ArgumentError, "#{label} must be a non-empty string" end - # Allow filters to access the underlying context - def absmartly_context - @absmartly_context + def validate_experiment_name(name) + validate_non_empty_string(name, 'Experiment name') end + + def validate_variable_key(key) + validate_non_empty_string(key, 'Variable key') + end + + def validate_field_name(name) + validate_non_empty_string(name, 'Field name') + end + + def validate_goal_name(name) + validate_non_empty_string(name, 'Goal name') + end + end end end diff --git a/lib/absmartly/liquid/filters.rb b/lib/absmartly/liquid/filters.rb index 7d7995f..b7db56d 100644 --- a/lib/absmartly/liquid/filters.rb +++ b/lib/absmartly/liquid/filters.rb @@ -1,54 +1,100 @@ +require_relative 'logging' + module ABsmartly module Liquid module Filters + include ABsmartly::Liquid::Logging def absmartly_treatment(experiment_name) - context = get_absmartly_context - return 0 unless context && context.ready? - - context.treatment(experiment_name) + with_ready_context(0) do |ctx| + ctx.treatment(experiment_name) + end + rescue StandardError => e + log_error("ABsmartly treatment error for '#{experiment_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + 0 end def absmartly_peek(experiment_name) - context = get_absmartly_context - return 0 unless context && context.ready? - - context.peek_treatment(experiment_name) + with_ready_context(0) do |ctx| + ctx.peek_treatment(experiment_name) + end + rescue StandardError => e + log_error("ABsmartly peek error for '#{experiment_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + 0 end def absmartly_variable(key, default_value) - context = get_absmartly_context - return default_value unless context && context.ready? - - context.variable_value(key, default_value) + with_ready_context(default_value) do |ctx| + ctx.variable_value(key, default_value) + end + rescue StandardError => e + log_error("ABsmartly variable error for '#{key}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + default_value end def absmartly_peek_variable(key, default_value) - context = get_absmartly_context - return default_value unless context && context.ready? - - context.peek_variable_value(key, default_value) + with_ready_context(default_value) do |ctx| + ctx.peek_variable_value(key, default_value) + end + rescue StandardError => e + log_error("ABsmartly peek_variable error for '#{key}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + default_value end def absmartly_custom_field(experiment_name, field_name) - context = get_absmartly_context - return nil unless context && context.ready? - - context.custom_field_value(experiment_name, field_name) + with_ready_context(nil) do |ctx| + ctx.custom_field_value(experiment_name, field_name) + end + rescue StandardError => e + log_error("ABsmartly custom_field error for '#{experiment_name}.#{field_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + nil end def absmartly_track(goal_name, properties = {}) - context = get_absmartly_context - return '' unless context - - context.track(goal_name, properties) + with_ready_context('') do |ctx| + ctx.track(goal_name, properties) + '' + end + rescue StandardError => e + log_error("ABsmartly track error for '#{goal_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode '' end private + def with_ready_context(fallback) + context = get_absmartly_context + + unless context + log_warning('ABsmartly context missing, returning fallback value') + raise 'ABsmartly context not available' if ABsmartly::Liquid.strict_mode + return fallback + end + + unless context.ready? + log_warning('ABsmartly context not ready, returning fallback value') + raise 'ABsmartly context not ready' if ABsmartly::Liquid.strict_mode + return fallback + end + + yield context + end + def get_absmartly_context - @context['absmartly']&.absmartly_context || - ABsmartly::Liquid.current_context + if @context + drop = @context['absmartly'] + if drop + ctx = drop.respond_to?(:absmartly_context) ? drop.absmartly_context : nil + return ctx if ctx + end + end + + ABsmartly::Liquid.current_context end end end diff --git a/lib/absmartly/liquid/logging.rb b/lib/absmartly/liquid/logging.rb new file mode 100644 index 0000000..8e3fb34 --- /dev/null +++ b/lib/absmartly/liquid/logging.rb @@ -0,0 +1,15 @@ +module ABsmartly + module Liquid + module Logging + private + + def log_warning(message) + ABsmartly::Liquid.logger&.warn("[ABsmartly Liquid SDK] #{message}") + end + + def log_error(message) + ABsmartly::Liquid.logger&.error("[ABsmartly Liquid SDK] #{message}") + end + end + end +end diff --git a/lib/absmartly/liquid/tags.rb b/lib/absmartly/liquid/tags.rb index 5a36949..61f651a 100644 --- a/lib/absmartly/liquid/tags.rb +++ b/lib/absmartly/liquid/tags.rb @@ -1,26 +1,36 @@ +require_relative 'logging' + module ABsmartly module Liquid module Tags class TreatmentTag < ::Liquid::Block - Syntax = /(\w+)/ - + include ABsmartly::Liquid::Logging def initialize(tag_name, markup, options) super - if markup =~ Syntax - @experiment_name = ::Liquid::Expression.parse($1) - else - raise ::Liquid::SyntaxError, "Syntax Error in 'absmartly_treatment' - Valid syntax: {% absmartly_treatment 'experiment_name' %}" + markup_trimmed = markup.strip + if markup_trimmed.empty? + raise ::Liquid::SyntaxError, "Syntax Error in 'absmartly_treatment' - Valid syntax: {% absmartly_treatment 'experiment_name' %}. Experiment names may contain letters, numbers, underscores, hyphens, and dots." end + + @experiment_name = ::Liquid::Expression.parse(markup_trimmed) end def render(context) experiment_name = context.evaluate(@experiment_name) absmartly = context['absmartly'] - variant = if absmartly && absmartly.respond_to?(:treatment) - absmartly.treatment(experiment_name) + variant = if absmartly && absmartly.respond_to?(:ready?) && absmartly.ready? + begin + absmartly.treatment(experiment_name) + rescue StandardError => e + log_error("Treatment error for '#{experiment_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + 0 + end else + log_warning("ABsmartly context missing or not ready for treatment '#{experiment_name}', returning control variant") + raise 'ABsmartly context not available or not ready' if ABsmartly::Liquid.strict_mode 0 end @@ -29,20 +39,23 @@ def render(context) super end end + end class TrackTag < ::Liquid::Tag - Syntax = /(\w+)(?:,\s*(.+))?/ - + include ABsmartly::Liquid::Logging def initialize(tag_name, markup, options) super - if markup =~ Syntax - @goal_name = ::Liquid::Expression.parse($1) - @properties_markup = $2 - else - raise ::Liquid::SyntaxError, "Syntax Error in 'absmartly_track' - Valid syntax: {% absmartly_track 'goal_name', key: value %}" + markup_trimmed = markup.strip + if markup_trimmed.empty? + raise ::Liquid::SyntaxError, "Syntax Error in 'absmartly_track' - Valid syntax: {% absmartly_track 'goal_name', key: value %}. Goal names may contain letters, numbers, underscores, hyphens, and dots." end + + # Split by comma to separate goal name from properties + parts = markup_trimmed.split(',', 2) + @goal_name = ::Liquid::Expression.parse(parts[0].strip) + @properties_markup = parts[1]&.strip end def render(context) @@ -50,7 +63,18 @@ def render(context) properties = parse_properties(context) absmartly = context['absmartly'] - absmartly.track(goal_name, properties) if absmartly && absmartly.respond_to?(:track) + + if absmartly && absmartly.respond_to?(:ready?) && absmartly.ready? + begin + absmartly.track(goal_name, properties) + rescue StandardError => e + log_error("Track error for '#{goal_name}': #{e.message}") + raise if ABsmartly::Liquid.strict_mode + end + else + log_warning("ABsmartly context missing or not ready, event '#{goal_name}' dropped") + raise 'ABsmartly context not available or not ready' if ABsmartly::Liquid.strict_mode + end '' end @@ -60,12 +84,18 @@ def render(context) def parse_properties(context) return {} unless @properties_markup - properties = {} - @properties_markup.scan(/(\w+):\s*([^,]+)/) do |key, value| - properties[key] = context.evaluate(value) + properties = @properties_markup.scan(/([\w\-\.]+):\s*([^,]+)/).to_h do |key, value| + evaluated = context.evaluate(value.strip) + [key, evaluated] end + + if properties.empty? && !@properties_markup.strip.empty? + log_warning("Failed to parse track properties: '#{@properties_markup}'") + end + properties end + end end end diff --git a/liquid-sdk.gemspec b/liquid-sdk.gemspec index 0d0b9ad..c264f5a 100644 --- a/liquid-sdk.gemspec +++ b/liquid-sdk.gemspec @@ -16,13 +16,8 @@ Gem::Specification.new do |spec| spec.metadata['source_code_uri'] = 'https://github.com/absmartly/liquid-sdk' spec.metadata['changelog_uri'] = 'https://github.com/absmartly/liquid-sdk/blob/main/CHANGELOG.md' - spec.files = Dir.chdir(File.expand_path(__dir__)) do - # Use git ls-files if in git repo, otherwise use Dir.glob - if File.directory?('.git') - `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) } - else - Dir.glob('**/*').reject { |f| File.directory?(f) || f.match(%r{\A(?:test|spec|features)/}) } - end + spec.files = Dir['lib/**/*', 'examples/**/*', 'README.md', 'LICENSE', 'CHANGELOG.md'].select do |f| + File.file?(f) && !f.match?(%r{\.DS_Store$}) end spec.bindir = 'exe' spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } diff --git a/spec/fixes_spec.rb b/spec/fixes_spec.rb new file mode 100644 index 0000000..60b7ffc --- /dev/null +++ b/spec/fixes_spec.rb @@ -0,0 +1,253 @@ +require 'spec_helper' +require 'liquid' +require 'logger' +require 'stringio' + +RSpec.describe 'Fix plan validations' do + let(:experiment_data) do + { + 'experiments' => [ + { + 'id' => 1, + 'name' => 'exp_test', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 0, + 'seedLo' => 1, + 'split' => [0.5, 0.5], + 'trafficSeedHi' => 0, + 'trafficSeedLo' => 0, + 'trafficSplit' => [1.0, 0.0], + 'fullOnVariant' => 0, + 'applications' => [{ 'name' => 'test' }], + 'variants' => [ + { 'name' => 'control', 'config' => '{"button_color":"blue"}' }, + { 'name' => 'treatment', 'config' => '{"button_color":"red"}' } + ], + 'audience' => '', + 'audienceStrict' => false + } + ] + } + end + + let(:event_collector) { TestEventCollector.new } + let(:context) do + create_test_context( + units: { session_id: 'test123' }, + data: experiment_data, + event_collector: event_collector + ) + end + let(:drop) { ABsmartly::Liquid::Drop.new(context) } + + let(:log_output) { StringIO.new } + let(:logger) { Logger.new(log_output) } + + before do + @original_logger = ABsmartly::Liquid.logger + @original_strict = ABsmartly::Liquid.strict_mode + ABsmartly::Liquid.logger = logger + ABsmartly::Liquid.strict_mode = false + end + + after do + ABsmartly::Liquid.logger = @original_logger + ABsmartly::Liquid.strict_mode = @original_strict + end + + describe 'Fix #1: current_context accessor' do + it 'exposes current_context accessor' do + expect(ABsmartly::Liquid).to respond_to(:current_context) + expect(ABsmartly::Liquid).to respond_to(:current_context=) + end + end + + describe 'Fix #2: absmartly_track uses with_ready_context' do + it 'returns empty string when context missing' do + template = Liquid::Template.parse("{{ 'purchase' | absmartly_track }}") + output = template.render({}) + expect(output).to eq('') + end + + it 'returns empty string when context not ready' do + not_ready_ctx = double('context', ready?: false) + not_ready_drop = ABsmartly::Liquid::Drop.new(not_ready_ctx) + + template = Liquid::Template.parse("{{ 'purchase' | absmartly_track }}") + output = template.render('absmartly' => not_ready_drop) + expect(output).to eq('') + end + + it 'tracks successfully when context is ready' do + template = Liquid::Template.parse("{{ 'purchase' | absmartly_track }}") + output = template.render('absmartly' => drop) + expect(output).to eq('') + expect(context.pending_count).to be >= 1 + end + end + + describe 'Fix #3: no redundant result variable in drop track' do + it 'returns track result directly' do + mock_ctx = double('context', track: 'tracked') + d = ABsmartly::Liquid::Drop.new(mock_ctx) + expect(d.track('goal')).to eq('tracked') + end + end + + describe 'Fix #5/#12: shared logging module' do + it 'Logging module provides log_warning' do + obj = Object.new + obj.extend(ABsmartly::Liquid::Logging) + expect(obj.respond_to?(:log_warning, true)).to eq(true) + end + + it 'Logging module provides log_error' do + obj = Object.new + obj.extend(ABsmartly::Liquid::Logging) + expect(obj.respond_to?(:log_error, true)).to eq(true) + end + + it 'Drop has log_warning via Logging module' do + expect(drop.respond_to?(:log_warning, true)).to eq(true) + end + + it 'Drop has log_error via Logging module' do + expect(drop.respond_to?(:log_error, true)).to eq(true) + end + end + + describe 'Fix #6: nil guard on @context access in filters' do + it 'returns fallback when @context is nil (no absmartly drop)' do + template = Liquid::Template.parse("{{ 'exp_test' | absmartly_treatment }}") + output = template.render({}) + expect(output).to eq('0') + end + + it 'returns fallback for variable when @context is nil' do + template = Liquid::Template.parse("{{ 'button_color' | absmartly_variable: 'default' }}") + output = template.render({}) + expect(output).to eq('default') + end + end + + describe 'Fix #13: strict mode propagation in filter outer rescue' do + it 'raises in strict mode when treatment filter hits an error' do + ABsmartly::Liquid.strict_mode = true + + error_ctx = double('context', ready?: true) + allow(error_ctx).to receive(:treatment).and_raise(StandardError, 'SDK error') + error_drop = ABsmartly::Liquid::Drop.new(error_ctx) + + template = Liquid::Template.parse("{{ 'exp_test' | absmartly_treatment }}") + expect { template.render!('absmartly' => error_drop) }.to raise_error(StandardError, /SDK error/) + end + + it 'raises in strict mode when peek filter hits an error' do + ABsmartly::Liquid.strict_mode = true + + error_ctx = double('context', ready?: true) + allow(error_ctx).to receive(:peek_treatment).and_raise(StandardError, 'SDK error') + error_drop = ABsmartly::Liquid::Drop.new(error_ctx) + + template = Liquid::Template.parse("{{ 'exp_test' | absmartly_peek }}") + expect { template.render!('absmartly' => error_drop) }.to raise_error(StandardError, /SDK error/) + end + + it 'raises in strict mode when variable filter hits an error' do + ABsmartly::Liquid.strict_mode = true + + error_ctx = double('context', ready?: true) + allow(error_ctx).to receive(:variable_value).and_raise(StandardError, 'SDK error') + error_drop = ABsmartly::Liquid::Drop.new(error_ctx) + + template = Liquid::Template.parse("{{ 'key' | absmartly_variable: 'default' }}") + expect { template.render!('absmartly' => error_drop) }.to raise_error(StandardError, /SDK error/) + end + + it 'raises in strict mode when custom_field filter hits an error' do + ABsmartly::Liquid.strict_mode = true + + error_ctx = double('context', ready?: true) + allow(error_ctx).to receive(:custom_field_value).and_raise(StandardError, 'SDK error') + error_drop = ABsmartly::Liquid::Drop.new(error_ctx) + + template = Liquid::Template.parse("{{ 'exp' | absmartly_custom_field: 'field' }}") + expect { template.render!('absmartly' => error_drop) }.to raise_error(StandardError, /SDK error/) + end + + it 'does not raise in non-strict mode when filter hits an error' do + ABsmartly::Liquid.strict_mode = false + + error_ctx = double('context', ready?: true) + allow(error_ctx).to receive(:treatment).and_raise(StandardError, 'SDK error') + error_drop = ABsmartly::Liquid::Drop.new(error_ctx) + + template = Liquid::Template.parse("{{ 'exp_test' | absmartly_treatment }}") + output = template.render('absmartly' => error_drop) + expect(output).to eq('0') + end + end + + describe 'Fix #14: get_absmartly_context no global fallback' do + it 'returns nil when drop is not in context' do + template = Liquid::Template.parse("{{ 'exp_test' | absmartly_treatment }}") + output = template.render({}) + expect(output).to eq('0') + expect(log_output.string).to include('context missing') + end + + it 'returns nil when drop does not respond to absmartly_context' do + fake_drop = 'not_a_drop' + template = Liquid::Template.parse("{{ 'exp_test' | absmartly_treatment }}") + output = template.render('absmartly' => fake_drop) + expect(output).to eq('0') + end + end + + describe 'Fix #15: unified validation in Drop' do + before { ABsmartly::Liquid.strict_mode = true } + + it 'validates experiment_name as non-empty string' do + expect { drop.treatment('') }.to raise_error(ArgumentError, /Experiment name/) + expect { drop.treatment(nil) }.to raise_error(ArgumentError, /Experiment name/) + expect { drop.treatment(123) }.to raise_error(ArgumentError, /Experiment name/) + end + + it 'validates variable_key as non-empty string' do + expect { drop.variable('', 'default') }.to raise_error(ArgumentError, /Variable key/) + expect { drop.variable(nil, 'default') }.to raise_error(ArgumentError, /Variable key/) + end + + it 'validates field_name as non-empty string' do + expect { drop.custom_field('exp', '') }.to raise_error(ArgumentError, /Field name/) + expect { drop.custom_field('exp', nil) }.to raise_error(ArgumentError, /Field name/) + end + + it 'validates goal_name as non-empty string' do + expect { drop.track('') }.to raise_error(ArgumentError, /Goal name/) + expect { drop.track(nil) }.to raise_error(ArgumentError, /Goal name/) + end + + it 'logs error for invalid input in non-strict mode' do + ABsmartly::Liquid.strict_mode = false + drop.treatment('') + expect(log_output.string).to include('Experiment name must be a non-empty string') + end + end + + describe 'Fix #9: consistent event shapes in test helper' do + it 'TestEventHandler.publish wraps events via handle_event' do + collector = TestEventCollector.new + handler = TestEventHandler.new(collector) + + handler.publish(nil, { goal: 'test' }) + + expect(collector.events.length).to eq(1) + expect(collector.events.first).to have_key(:type) + expect(collector.events.first[:type]).to eq('publish') + expect(collector.events.first).to have_key(:data) + expect(collector.events.first).to have_key(:timestamp) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index c8a7037..66c589d 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,6 +6,9 @@ # Ruby SDK is already loaded via absmartly/liquid which requires 'absmartly' +# Load support files +Dir[File.join(__dir__, 'support', '**', '*.rb')].each { |f| require f } + SimpleCov.start do add_filter '/spec/' end @@ -34,6 +37,7 @@ def initialize(event_collector) end def publish(context, event) + @event_collector.handle_event(:publish, event) self end end diff --git a/spec/support/test_data.rb b/spec/support/test_data.rb new file mode 100644 index 0000000..28780e8 --- /dev/null +++ b/spec/support/test_data.rb @@ -0,0 +1,152 @@ +module TestDataHelpers + def build_experiment_data + { + 'experiments' => [ + { + 'id' => 1, + 'name' => 'exp_test_ab', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 0x00000000, + 'seedLo' => 0x00000000, + 'split' => [0.5, 0.5], + 'trafficSeedHi' => 0x00000000, + 'trafficSeedLo' => 0x00000000, + 'trafficSplit' => [0.0, 1.0], + 'fullOnVariant' => 0, + 'applications' => [ + { + 'name' => 'website' + } + ], + 'variants' => [ + { + 'name' => 'A', + 'config' => { + 'banner.border' => 1, + 'banner.size' => 'large' + }.to_json + }, + { + 'name' => 'B', + 'config' => { + 'banner.border' => 0, + 'banner.size' => 'small' + }.to_json + } + ] + }, + { + 'id' => 2, + 'name' => 'exp_test_abc', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 0x00000000, + 'seedLo' => 0x00000001, + 'split' => [0.33, 0.33, 0.34], + 'trafficSeedHi' => 0x00000000, + 'trafficSeedLo' => 0x00000001, + 'trafficSplit' => [0.0, 1.0], + 'fullOnVariant' => 0, + 'applications' => [ + { + 'name' => 'website' + } + ], + 'variants' => [ + { + 'name' => 'A', + 'config' => nil + }, + { + 'name' => 'B', + 'config' => nil + }, + { + 'name' => 'C', + 'config' => nil + } + ] + }, + { + 'id' => 3, + 'name' => 'exp_test_not_eligible', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 0x00000000, + 'seedLo' => 0x00000002, + 'split' => [0.5, 0.5], + 'trafficSeedHi' => 0x00000000, + 'trafficSeedLo' => 0x00000002, + 'trafficSplit' => [0.99, 0.01], + 'fullOnVariant' => 0, + 'applications' => [ + { + 'name' => 'website' + } + ], + 'variants' => [ + { + 'name' => 'A', + 'config' => nil + }, + { + 'name' => 'B', + 'config' => nil + } + ] + }, + { + 'id' => 4, + 'name' => 'exp_test_fullon', + 'unitType' => 'session_id', + 'iteration' => 1, + 'seedHi' => 0x00000000, + 'seedLo' => 0x00000003, + 'split' => [0.25, 0.25, 0.25, 0.25], + 'trafficSeedHi' => 0x00000000, + 'trafficSeedLo' => 0x00000003, + 'trafficSplit' => [0.0, 1.0], + 'fullOnVariant' => 2, + 'applications' => [ + { + 'name' => 'website' + } + ], + 'variants' => [ + { + 'name' => 'A', + 'config' => nil + }, + { + 'name' => 'B', + 'config' => nil + }, + { + 'name' => 'C', + 'config' => nil + }, + { + 'name' => 'D', + 'config' => nil + } + ] + } + ] + } + end +end + +RSpec.shared_context 'with absmartly test context' do + include TestDataHelpers + + let(:experiment_data) { build_experiment_data } + let(:event_collector) { TestEventCollector.new } + let(:units) { { session_id: 'test-session-123' } } + let(:context) { create_test_context(units: units, data: experiment_data, event_collector: event_collector) } + let(:drop) { ABsmartly::Liquid::Drop.new(context) } +end + +RSpec.configure do |config| + config.include TestDataHelpers +end