forked from hedyorg/hedy
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Session variable support for A/B testing (hedyorg#444)
* Session variable support for A/B testing. * Passing env variables between main & test, WIP. * Add e2e tests for session variable support in a/b testing; fix cookie parsing from test to main. * Instead of doing our own decoding, switch to using `flask`/`itsdangerous` cookie parsing * Move code to a separate file and fix some tests * Also factor out CDN logic to its own module * Update documentation * Move function that's only used for A/B testing into A/B module * Rename ab test script; move script to tests folder. Co-authored-by: Rico Huijbers <[email protected]>
- Loading branch information
Showing
11 changed files
with
306 additions
and
100 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,114 @@ | ||
from http.cookies import SimpleCookie | ||
import hashlib | ||
import re | ||
import os | ||
import logging | ||
|
||
from flask import request, session, make_response | ||
import requests | ||
import flask.sessions | ||
import itsdangerous | ||
|
||
from auth import current_user | ||
import utils | ||
|
||
class ABProxying: | ||
"""Proxy some requests to another server.""" | ||
def __init__(self, app, target_host, secret_key): | ||
self.target_host = target_host | ||
self.secret_key = secret_key | ||
|
||
app.before_request(self.before_request_proxy) | ||
|
||
def before_request_proxy(self): | ||
# If it is an auth route, we do not reverse proxy it to the PROXY_TO_TEST_HOST environment, with the exception of /auth/texts | ||
# We want to keep all cookie setting in the main environment, not the test one. | ||
if re.match ('.*/auth/.*', request.url) and not re.match ('.*/auth/texts', request.url): | ||
pass | ||
# This route is meant to return the session from the main environment, for testing purposes. | ||
elif re.match ('.*/session_main', request.url): | ||
pass | ||
# If we enter this block, we will reverse proxy the request to the PROXY_TO_TEST_HOST environment. | ||
# /session_test is meant to return the session from the test environment, for testing purposes. | ||
elif re.match ('.*/session_test', request.url) or redirect_ab (request): | ||
url = self.target_host + request.full_path | ||
logging.debug('Proxying %s %s %s to %s', request.method, request.url, dict (session), url) | ||
|
||
request_headers = {} | ||
for header in request.headers: | ||
if (header [0].lower () in ['host']): | ||
continue | ||
request_headers [header [0]] = header [1] | ||
# In case the session_id is not yet set in the cookie, pass it in a special header | ||
request_headers ['X-session_id'] = session ['session_id'] | ||
|
||
r = getattr (requests, request.method.lower ()) (url, headers=request_headers, data=request.data) | ||
|
||
response = make_response (r.content) | ||
for header in r.headers: | ||
# With great help from https://medium.com/customorchestrator/simple-reverse-proxy-server-using-flask-936087ce0afb | ||
if header.lower () in ['content-encoding', 'content-length', 'transfer-encoding', 'connection']: | ||
continue | ||
# Setting the session cookie returned by the test environment into the response won't work because it will be overwritten by Flask, so we need to read the cookie into the session so that then the session cookie can be updated by Flask | ||
if header.lower () == 'set-cookie': | ||
proxied_session = extract_session_from_cookie (r.headers [header], self.secret_key) | ||
for key in proxied_session: | ||
session [key] = proxied_session [key] | ||
continue | ||
response.headers [header] = r.headers [header] | ||
|
||
return response, r.status_code | ||
|
||
|
||
def redirect_ab (request): | ||
# If this is a testing request, we return True | ||
if utils.is_testing_request (request): | ||
return True | ||
# If the user is logged in, we use their username as identifier, otherwise we use the session id | ||
user_identifier = current_user(request) ['username'] or str (session['session_id']) | ||
|
||
# This will send either % PROXY_TO_TEST_PROPORTION of the requests into redirect, or 50% if that variable is not specified. | ||
redirect_proportion = int (os.getenv ('PROXY_TO_TEST_PROPORTION', '50')) | ||
redirect_flag = (hash_user_or_session (user_identifier) % 100) < redirect_proportion | ||
return redirect_flag | ||
|
||
|
||
def hash_user_or_session (string): | ||
hash = hashlib.md5 (string.encode ('utf-8')).hexdigest () | ||
return int (hash, 16) | ||
|
||
|
||
# Used by A/B testing to extract a session from a set-cookie header. | ||
# The signature is ignored. The source of the session should be trusted. | ||
def extract_session_from_cookie (cookie_header, secret_key): | ||
parsed_cookie = SimpleCookie (cookie_header) | ||
if not 'session' in parsed_cookie: | ||
return {} | ||
|
||
cookie_interface = flask.sessions.SecureCookieSessionInterface() | ||
|
||
# This code matches what Flask does for encoding | ||
serializer = itsdangerous.URLSafeTimedSerializer( | ||
secret_key, | ||
salt=cookie_interface.salt, | ||
serializer=cookie_interface.serializer, | ||
signer_kwargs=dict( | ||
key_derivation=cookie_interface.key_derivation, | ||
digest_method=cookie_interface.digest_method | ||
)) | ||
|
||
try: | ||
cookie_value = parsed_cookie['session'].value | ||
return serializer.loads(cookie_value) | ||
except itsdangerous.exc.BadSignature as e: | ||
# If the signature is wrong, we can still decode the cookie. | ||
# We try to do it properly with the actual key though, because exception | ||
# handling is slightly slow in Python and doing it successfully is therefore | ||
# faster. | ||
if e.payload is not None: | ||
try: | ||
return serializer.load_payload(e.payload) | ||
except itsdangerous.exc.BadData: | ||
pass | ||
return {} | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,52 @@ | ||
import utils | ||
|
||
class Cdn: | ||
"""Set up CDN configuration. | ||
Add a global to the template (called `static()`) to return references to | ||
static resources. All static resources should be referenced using this | ||
function. | ||
If CDN is not enabled: | ||
static('hello.jpg') -> '/hello.jpg' (fetch from the current server) | ||
If enabled (when a CDN prefix is given), add an additional | ||
route with a unique name containing the server commit to return | ||
static resources. | ||
If CDN is enabled: | ||
static('hello.jpg') -> 'https://1235.cdn.com/s-830s8a2fa/hello.jpg' (fetch from CDN) | ||
Because the commit number is in the URL, it can be extremely aggressively | ||
cached by the CDN. | ||
""" | ||
def __init__(self, app, cdn_prefix, commit): | ||
self.cdn_prefix = cdn_prefix or '' | ||
self.commit = commit | ||
self.static_prefix = '/' | ||
|
||
if self.cdn_prefix: | ||
# If we are using a CDN, also host static resources under a URL that includes | ||
# the version number (so the CDN can aggressively cache the static assets and we | ||
# still can invalidate them whenever necessary). | ||
# | ||
# The function {{static('/js/bla.js')}} can be used to retrieve the URL of static | ||
# assets, either from the CDN if configured or just the normal URL we would use | ||
# without a CDN. | ||
# | ||
# We still keep on hosting static assets in the "old" location as well for images in | ||
# emails and content we forgot to replace or are unable to replace (like in MarkDowns). | ||
self.static_prefix = '/static-' + commit | ||
app.add_url_rule(self.static_prefix + '/<path:filename>', | ||
endpoint="static", | ||
view_func=app.send_static_file) | ||
|
||
app.add_template_global(self.static, name='static') | ||
|
||
def static(self, url): | ||
"""Return cacheable links to static resources.""" | ||
return utils.slash_join(self.cdn_prefix, self.static_prefix, url) | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.